From e82f2d768ef2fa541205cb5cd25ee27804a0c68f Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Wed, 8 Nov 2023 12:27:31 +0100 Subject: [PATCH 01/13] BC-5695-RocketChat-user-deletion (#4523) * first commit * add some tests * add test cases and services * add usecases and test cases * fix importing * add type in uc * fix import * implement entity, domain, mapper and repo for rocketChat * fix most of issue form review * add some test,additional status, limit ... * implement rocketChat User deletion * add service to module * small fixes during review * implement deletion rocketChat user in KNL deletion module * small fixes * changing limit parameter and changing in useCases * delete 3 lines of code * Update apps/server/src/modules/deletion/uc/deletion-request.uc.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * Update apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * small fixes * Update apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * import fix --------- Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> --- .../types/deletion-domain-model.enum.ts | 1 + .../deletion/uc/deletion-request.uc.spec.ts | 54 +++++++- .../deletion/uc/deletion-request.uc.ts | 18 ++- .../modules/rocketchat-user/domain/index.ts | 1 + .../domain/rocket-chat-user.do.spec.ts | 67 +++++++++ .../domain/rocket-chat-user.do.ts | 37 +++++ .../rocketchat-user/domain/testing/index.ts | 1 + .../testing/rocket-chat-user.factory.ts | 18 +++ .../modules/rocketchat-user/entity/index.ts | 1 + .../entity/rocket-chat-user.entity.spec.ts | 61 ++++++++ .../entity/rocket-chat-user.entity.ts | 56 ++++++++ .../rocketchat-user/entity/testing/index.ts | 1 + .../rocket-chat-user.entity.factory.ts | 20 +++ .../src/modules/rocketchat-user/index.ts | 3 + .../src/modules/rocketchat-user/repo/index.ts | 1 + .../rocketchat-user/repo/mapper/index.ts | 1 + .../mapper/rocket-chat-user.mapper.spec.ts | 61 ++++++++ .../repo/mapper/rocket-chat-user.mapper.ts | 29 ++++ .../repo/rocket-chat-user.repo.spec.ts | 131 ++++++++++++++++++ .../repo/rocket-chat-user.repo.ts | 33 +++++ .../rocketchat-user/rocketchat-user.module.ts | 10 ++ .../modules/rocketchat-user/service/index.ts | 1 + .../service/rocket-chat-user.service.spec.ts | 94 +++++++++++++ .../service/rocket-chat-user.service.ts | 19 +++ 24 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/modules/rocketchat-user/domain/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts create mode 100644 apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts create mode 100644 apps/server/src/modules/rocketchat-user/domain/testing/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts create mode 100644 apps/server/src/modules/rocketchat-user/entity/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts create mode 100644 apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts create mode 100644 apps/server/src/modules/rocketchat-user/entity/testing/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts create mode 100644 apps/server/src/modules/rocketchat-user/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/mapper/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts create mode 100644 apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts create mode 100644 apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts create mode 100644 apps/server/src/modules/rocketchat-user/service/index.ts create mode 100644 apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts create mode 100644 apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts diff --git a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts index dbfc2e06d8d..1a4f3bcf425 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts +++ b/apps/server/src/modules/deletion/domain/types/deletion-domain-model.enum.ts @@ -6,6 +6,7 @@ export const enum DeletionDomainModel { FILE = 'file', LESSONS = 'lessons', PSEUDONYMS = 'pseudonyms', + ROCKETCHATUSER = 'rocketChatUser', TEAMS = 'teams', USER = 'user', } diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts index 063f3d46b48..34c34e302f5 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts @@ -9,14 +9,17 @@ import { LessonService } from '@modules/lesson/service'; import { PseudonymService } from '@modules/pseudonym'; import { TeamService } from '@modules/teams'; import { UserService } from '@modules/user'; +import { RocketChatService } from '@modules/rocketchat'; +import { rocketChatUserFactory } from '@modules/rocketchat-user/domain/testing'; +import { RocketChatUser, RocketChatUserService } from '@modules/rocketchat-user'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; import { DeletionRequestService } from '../services'; import { DeletionRequestUc } from './deletion-request.uc'; -import { DeletionRequestLog, DeletionRequestProps } from './interface/interfaces'; import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; import { DeletionStatusModel } from '../domain/types/deletion-status-model.enum'; import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; +import { DeletionRequestLog, DeletionRequestProps } from './interface'; describe(DeletionRequestUc.name, () => { let module: TestingModule; @@ -32,6 +35,8 @@ describe(DeletionRequestUc.name, () => { let pseudonymService: DeepMocked; let teamService: DeepMocked; let userService: DeepMocked; + let rocketChatUserService: DeepMocked; + let rocketChatService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -81,6 +86,14 @@ describe(DeletionRequestUc.name, () => { provide: UserService, useValue: createMock(), }, + { + provide: RocketChatUserService, + useValue: createMock(), + }, + { + provide: RocketChatService, + useValue: createMock(), + }, ], }).compile(); @@ -96,6 +109,8 @@ describe(DeletionRequestUc.name, () => { pseudonymService = module.get(PseudonymService); teamService = module.get(TeamService); userService = module.get(UserService); + rocketChatUserService = module.get(RocketChatUserService); + rocketChatService = module.get(RocketChatService); await setupEntities(); }); @@ -153,6 +168,9 @@ describe(DeletionRequestUc.name, () => { const setup = () => { jest.clearAllMocks(); const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ + userId: deletionRequestToExecute.targetRefId, + }); classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); @@ -163,9 +181,11 @@ describe(DeletionRequestUc.name, () => { pseudonymService.deleteByUserId.mockResolvedValueOnce(2); teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); userService.deleteUser.mockResolvedValueOnce(1); + rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); return { deletionRequestToExecute, + rocketChatUser, }; }; @@ -287,6 +307,38 @@ describe(DeletionRequestUc.name, () => { expect(userService.deleteUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); }); + it('should call rocketChatUserService.findByUserId to find rocketChatUser in rocketChatUser module', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(rocketChatUserService.findByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call rocketChatUserService.deleteByUserId to delete rocketChatUser in rocketChatUser module', async () => { + const { deletionRequestToExecute, rocketChatUser } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); + + await uc.executeDeletionRequests(); + + expect(rocketChatUserService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + + it('should call rocketChatService.deleteUser to delete rocketChatUser in rocketChat external module', async () => { + const { deletionRequestToExecute, rocketChatUser } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); + + await uc.executeDeletionRequests(); + + expect(rocketChatService.deleteUser).toHaveBeenCalledWith(rocketChatUser.username); + }); + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { const { deletionRequestToExecute } = setup(); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts index d94a129310f..abea56fda96 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -8,6 +8,8 @@ import { LessonService } from '@modules/lesson/service'; import { CourseGroupService, CourseService } from '@modules/learnroom/service'; import { FilesService } from '@modules/files/service'; import { AccountService } from '@modules/account/services'; +import { RocketChatUserService } from '@modules/rocketchat-user'; +import { RocketChatService } from '@modules/rocketchat'; import { DeletionRequestService } from '../services/deletion-request.service'; import { DeletionDomainModel } from '../domain/types/deletion-domain-model.enum'; import { DeletionLogService } from '../services/deletion-log.service'; @@ -38,7 +40,9 @@ export class DeletionRequestUc { private readonly lessonService: LessonService, private readonly pseudonymService: PseudonymService, private readonly teamService: TeamService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly rocketChatUserService: RocketChatUserService, + private readonly rocketChatService: RocketChatService ) {} async createDeletionRequest(deletionRequest: DeletionRequestProps): Promise { @@ -96,6 +100,7 @@ export class DeletionRequestUc { this.removeUsersPseudonyms(deletionRequest), this.removeUserFromTeams(deletionRequest), this.removeUser(deletionRequest), + this.removeUserFromRocketChat(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -206,4 +211,15 @@ export class DeletionRequestUc { const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); await this.logDeletion(deletionRequest, DeletionDomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); } + + private async removeUserFromRocketChat(deletionRequest: DeletionRequest): Promise { + const rocketChatUser = await this.rocketChatUserService.findByUserId(deletionRequest.targetRefId); + + const [, rocketChatUserDeleted] = await Promise.all([ + this.rocketChatService.deleteUser(rocketChatUser.username), + this.rocketChatUserService.deleteByUserId(rocketChatUser.userId), + ]); + + return rocketChatUserDeleted; + } } diff --git a/apps/server/src/modules/rocketchat-user/domain/index.ts b/apps/server/src/modules/rocketchat-user/domain/index.ts new file mode 100644 index 00000000000..0246dd0f0f9 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.do'; diff --git a/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts new file mode 100644 index 00000000000..a1be448f80c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.spec.ts @@ -0,0 +1,67 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUser } from './rocket-chat-user.do'; +import { rocketChatUserFactory } from './testing/rocket-chat-user.factory'; + +describe(RocketChatUser.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a rocketChatUser by passing required properties', () => { + const domainObject: RocketChatUser = rocketChatUserFactory.build(); + + expect(domainObject instanceof RocketChatUser).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: RocketChatUser = rocketChatUserFactory.build(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const rocketChatUserObject: RocketChatUser = new RocketChatUser(domainObject); + + expect(rocketChatUserObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + username: 'Test.User.shls', + rcId: 'JfMJXua6t29KYXdDc', + authToken: 'OL8e5YCZHy3agGnLS-gHAx1wU4ZCG8-DXU_WZnUxUu6', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const rocketChatUserDo = new RocketChatUser(props); + + return { props, rocketChatUserDo }; + }; + + it('getters should return proper values', () => { + const { props, rocketChatUserDo } = setup(); + + const gettersValues = { + id: rocketChatUserDo.id, + userId: rocketChatUserDo.userId, + username: rocketChatUserDo.username, + rcId: rocketChatUserDo.rcId, + authToken: rocketChatUserDo.authToken, + createdAt: rocketChatUserDo.createdAt, + updatedAt: rocketChatUserDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts new file mode 100644 index 00000000000..8dfd830f3eb --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/rocket-chat-user.do.ts @@ -0,0 +1,37 @@ +import { EntityId } from '@shared/domain/types'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; + +export interface RocketChatUserProps extends AuthorizableObject { + userId: EntityId; + username: string; + rcId: string; + authToken?: string; + createdAt?: Date; + updatedAt?: Date; +} + +export class RocketChatUser extends DomainObject { + get userId(): EntityId { + return this.props.userId; + } + + get username(): string { + return this.props.username; + } + + get rcId(): string { + return this.props.rcId; + } + + get authToken(): string | undefined { + return this.props.authToken; + } + + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } +} diff --git a/apps/server/src/modules/rocketchat-user/domain/testing/index.ts b/apps/server/src/modules/rocketchat-user/domain/testing/index.ts new file mode 100644 index 00000000000..2ef434c0975 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/testing/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.factory'; diff --git a/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts b/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts new file mode 100644 index 00000000000..3ad6432d1d5 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/domain/testing/rocket-chat-user.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RocketChatUser, RocketChatUserProps } from '../rocket-chat-user.do'; + +export const rocketChatUserFactory = BaseFactory.define( + RocketChatUser, + ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + username: `username-${sequence}`, + rcId: `rcId-${sequence}`, + authToken: `aythToken-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/rocketchat-user/entity/index.ts b/apps/server/src/modules/rocketchat-user/entity/index.ts new file mode 100644 index 00000000000..9528e8da500 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.entity'; diff --git a/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts new file mode 100644 index 00000000000..f8d5318c5bf --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.spec.ts @@ -0,0 +1,61 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; + +describe(RocketChatUserEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + userId: new ObjectId(), + username: 'Test.User.shls', + rcId: 'JfMJXua6t29KYXdDc', + authToken: 'OL8e5YCZHy3agGnLS-gHAx1wU4ZCG8-DXU_WZnUxUu6', + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + + describe('constructor', () => { + describe('When constructor is called', () => { + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new RocketChatUserEntity(); + expect(test).toThrow(); + }); + + it('should create a rocketChatUser by passing required properties', () => { + const { props } = setup(); + const entity: RocketChatUserEntity = new RocketChatUserEntity(props); + + expect(entity instanceof RocketChatUserEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: RocketChatUserEntity = new RocketChatUserEntity(props); + + const entityProps = { + id: entity.id, + userId: entity.userId, + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts new file mode 100644 index 00000000000..6df469e0ddb --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/rocket-chat-user.entity.ts @@ -0,0 +1,56 @@ +import { Entity, Index, Property, Unique } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain'; + +export interface RocketChatUserEntityProps { + id?: EntityId; + userId: ObjectId; + username: string; + rcId: string; + authToken?: string; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'rocketchatuser' }) +export class RocketChatUserEntity extends BaseEntityWithTimestamps { + @Property() + @Unique() + username: string; + + @Property() + @Unique() + userId: ObjectId; + + @Property() + @Index() + rcId: string; + + @Property({ nullable: true }) + authToken?: string; + + constructor(props: RocketChatUserEntityProps) { + super(); + + if (props.id !== undefined) { + this.id = props.id; + } + + this.userId = props.userId; + this.username = props.username; + this.rcId = props.rcId; + + if (props.authToken !== undefined) { + this.authToken = props.authToken; + } + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } +} diff --git a/apps/server/src/modules/rocketchat-user/entity/testing/index.ts b/apps/server/src/modules/rocketchat-user/entity/testing/index.ts new file mode 100644 index 00000000000..f19ebd8c74a --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.entity.factory'; diff --git a/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts b/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts new file mode 100644 index 00000000000..302459a4eb6 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/entity/testing/rocket-chat-user.entity.factory.ts @@ -0,0 +1,20 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { RocketChatUserEntity, RocketChatUserEntityProps } from '../rocket-chat-user.entity'; + +class RocketChatUserFactory extends BaseFactory {} + +export const rocketChatUserEntityFactory = RocketChatUserFactory.define< + RocketChatUserEntity, + RocketChatUserEntityProps +>(RocketChatUserEntity, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + userId: new ObjectId(), + username: `username-${sequence}`, + rcId: `rcId-${sequence}`, + authToken: `aythToken-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/modules/rocketchat-user/index.ts b/apps/server/src/modules/rocketchat-user/index.ts new file mode 100644 index 00000000000..34ae0f25f87 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/index.ts @@ -0,0 +1,3 @@ +export * from './rocketchat-user.module'; +export * from './service'; +export * from './domain'; diff --git a/apps/server/src/modules/rocketchat-user/repo/index.ts b/apps/server/src/modules/rocketchat-user/repo/index.ts new file mode 100644 index 00000000000..b05b92fc380 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.repo'; diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts new file mode 100644 index 00000000000..7a33e93289e --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.mapper'; diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts new file mode 100644 index 00000000000..bd5a07abb5c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts @@ -0,0 +1,61 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUser } from '../../domain/rocket-chat-user.do'; +import { rocketChatUserFactory } from '../../domain/testing/rocket-chat-user.factory'; +import { RocketChatUserEntity } from '../../entity'; +import { rocketChatUserEntityFactory } from '../../entity/testing/rocket-chat-user.entity.factory'; +import { RocketChatUserMapper } from './rocket-chat-user.mapper'; + +describe(RocketChatUserMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + it('should properly map the entity to the domain object', () => { + const entity = rocketChatUserEntityFactory.build(); + + const domainObject = RocketChatUserMapper.mapToDO(entity); + + const expectedDomainObject = new RocketChatUser({ + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should properly map the domainObject to the entity', () => { + const domainObject = rocketChatUserFactory.build(); + + const entity = RocketChatUserMapper.mapToEntity(domainObject); + + const expectedEntity = new RocketChatUserEntity({ + id: domainObject.id, + userId: new ObjectId(domainObject.userId), + username: domainObject.username, + rcId: domainObject.rcId, + authToken: domainObject.authToken, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + expect(entity).toEqual(expectedEntity); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts new file mode 100644 index 00000000000..3d45c9c34ac --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts @@ -0,0 +1,29 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { RocketChatUserEntity } from '../../entity'; +import { RocketChatUser } from '../../domain/rocket-chat-user.do'; + +export class RocketChatUserMapper { + static mapToDO(entity: RocketChatUserEntity): RocketChatUser { + return new RocketChatUser({ + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + } + + static mapToEntity(domainObject: RocketChatUser): RocketChatUserEntity { + return new RocketChatUserEntity({ + id: domainObject.id, + userId: new ObjectId(domainObject.userId), + username: domainObject.username, + rcId: domainObject.rcId, + authToken: domainObject.authToken, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + } +} diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts new file mode 100644 index 00000000000..d58e5fc42d1 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts @@ -0,0 +1,131 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { RocketChatUserMapper } from './mapper'; +import { RocketChatUserEntity } from '../entity'; +import { RocketChatUserRepo } from './rocket-chat-user.repo'; +import { RocketChatUser } from '../domain'; +import { rocketChatUserEntityFactory } from '../entity/testing'; + +describe(RocketChatUserRepo.name, () => { + let module: TestingModule; + let repo: RocketChatUserRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [RocketChatUserEntity], + }), + ], + providers: [RocketChatUserRepo, RocketChatUserMapper], + }).compile(); + + repo = module.get(RocketChatUserRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + expect(typeof repo.findByUserId).toEqual('function'); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(RocketChatUserEntity); + }); + }); + + describe('findByUserId', () => { + describe('when searching rocketChatUser by userId', () => { + const setup = async () => { + const userId = new ObjectId(); + const entity: RocketChatUserEntity = rocketChatUserEntityFactory.build({ userId }); + await em.persistAndFlush(entity); + em.clear(); + const expectedRocketChatUser = { + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedRocketChatUser, + }; + }; + + it('should find the rocketChatUser', async () => { + const { entity, expectedRocketChatUser } = await setup(); + + const result: RocketChatUser = await repo.findByUserId(entity.userId.toHexString()); + + // Verify explicit fields. + expect(result).toEqual(expect.objectContaining(expectedRocketChatUser)); + }); + }); + }); + + describe('deleteByUserId', () => { + describe('when deleting rocketChatUser exists', () => { + const setup = async () => { + const entity: RocketChatUserEntity = rocketChatUserEntityFactory.build(); + const rocketChatUserId = entity.userId.toHexString(); + await em.persistAndFlush(entity); + em.clear(); + + return { rocketChatUserId }; + }; + + it('should delete the rocketChatUSer with userId', async () => { + const { rocketChatUserId } = await setup(); + + await repo.deleteByUserId(rocketChatUserId); + + expect(await em.findOne(RocketChatUserEntity, { userId: new ObjectId(rocketChatUserId) })).toBeNull(); + }); + + it('should return number equal 1', async () => { + const { rocketChatUserId } = await setup(); + + const result: number = await repo.deleteByUserId(rocketChatUserId); + + expect(result).toEqual(1); + }); + }); + + describe('when no rocketChatUser exists', () => { + const setup = () => { + const rocketChatUserId = new ObjectId().toHexString(); + + return { rocketChatUserId }; + }; + + it('should return false', async () => { + const { rocketChatUserId } = setup(); + + const result: number = await repo.deleteByUserId(rocketChatUserId); + + expect(result).toEqual(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts new file mode 100644 index 00000000000..741f297f804 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts @@ -0,0 +1,33 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { RocketChatUserEntity } from '../entity'; +import { RocketChatUser } from '../domain/rocket-chat-user.do'; +import { RocketChatUserMapper } from './mapper'; + +@Injectable() +export class RocketChatUserRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return RocketChatUserEntity; + } + + async findByUserId(userId: EntityId): Promise { + const entity: RocketChatUserEntity = await this.em.findOneOrFail(RocketChatUserEntity, { + userId: new ObjectId(userId), + }); + + const mapped: RocketChatUser = RocketChatUserMapper.mapToDO(entity); + + return mapped; + } + + async deleteByUserId(userId: EntityId): Promise { + const promise: Promise = this.em.nativeDelete(RocketChatUserEntity, { + userId: new ObjectId(userId), + }); + + return promise; + } +} diff --git a/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts new file mode 100644 index 00000000000..798b2276a4d --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RocketChatUserRepo } from './repo'; +import { RocketChatUserService } from './service/rocket-chat-user.service'; +import { RocketChatService } from '../rocketchat/rocket-chat.service'; + +@Module({ + providers: [RocketChatUserService, RocketChatUserRepo], + exports: [RocketChatService], +}) +export class RocketChatUserModule {} diff --git a/apps/server/src/modules/rocketchat-user/service/index.ts b/apps/server/src/modules/rocketchat-user/service/index.ts new file mode 100644 index 00000000000..350217d4e38 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/index.ts @@ -0,0 +1 @@ +export * from './rocket-chat-user.service'; diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts new file mode 100644 index 00000000000..dd8ae17667c --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -0,0 +1,94 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities } from '@shared/testing'; +import { RocketChatUserService } from './rocket-chat-user.service'; +import { RocketChatUserRepo } from '../repo'; +import { rocketChatUserFactory } from '../domain/testing/rocket-chat-user.factory'; +import { RocketChatUser } from '../domain'; + +describe(RocketChatUserService.name, () => { + let module: TestingModule; + let service: RocketChatUserService; + let rocketChatUserRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + RocketChatUserService, + { + provide: RocketChatUserRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(RocketChatUserService); + rocketChatUserRepo = module.get(RocketChatUserRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findByUserId', () => { + describe('when searching rocketChatUser', () => { + const setup = () => { + const userId: string = new ObjectId().toHexString(); + + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build(); + + rocketChatUserRepo.findByUserId.mockResolvedValueOnce(rocketChatUser); + + return { + userId, + rocketChatUser, + }; + }; + + it('should return the rocketChatUser', async () => { + const { userId, rocketChatUser } = setup(); + + const result: RocketChatUser = await service.findByUserId(userId); + + expect(result).toEqual(rocketChatUser); + }); + }); + }); + + describe('deleteUserDataFromClasses', () => { + describe('when deleting rocketChatUser', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + rocketChatUserRepo.deleteByUserId.mockResolvedValueOnce(1); + + return { + userId, + }; + }; + + it('should call rocketChatUserRepo', async () => { + const { userId } = setup(); + + await service.deleteByUserId(userId); + + expect(rocketChatUserRepo.deleteByUserId).toBeCalledWith(userId); + }); + + it('should delete rocketChatUser by userId', async () => { + const { userId } = setup(); + + const result: number = await service.deleteByUserId(userId); + + expect(result).toEqual(1); + }); + }); + }); +}); diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts new file mode 100644 index 00000000000..32a600c0f75 --- /dev/null +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain'; +import { RocketChatUserRepo } from '../repo'; +import { RocketChatUser } from '../domain'; + +@Injectable() +export class RocketChatUserService { + constructor(private readonly rocketChatUserRepo: RocketChatUserRepo) {} + + public async findByUserId(userId: EntityId): Promise { + const user: RocketChatUser = await this.rocketChatUserRepo.findByUserId(userId); + + return user; + } + + public deleteByUserId(userId: EntityId): Promise { + return this.rocketChatUserRepo.deleteByUserId(userId); + } +} From 33381e6834014743f33866d1c02def649930d466 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Wed, 8 Nov 2023 14:09:48 +0100 Subject: [PATCH 02/13] N21-1398 hide mail section for ldap (#4535) * makes ldap to external system for skipping email section on first login --- .../controllers/api-test/login.api.spec.ts | 3 +- .../modules/authentication/interface/index.ts | 1 + .../interface/oauth-current-user.ts | 6 ++ .../modules/authentication/interface/user.ts | 7 +- .../mapper/current-user.mapper.spec.ts | 96 +++++++++++++------ .../mapper/current-user.mapper.ts | 4 +- .../strategy/ldap.strategy.spec.ts | 4 +- .../authentication/strategy/ldap.strategy.ts | 4 +- .../authentication/strategy/local.strategy.ts | 2 +- 9 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 apps/server/src/modules/authentication/interface/oauth-current-user.ts diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 7da3c21eab9..04683e182a8 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -192,6 +192,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).toHaveProperty('isExternalUser'); expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -287,7 +288,7 @@ describe('Login Controller (api)', () => { roles: [studentRole.id], schoolId: school.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); expect(decodedToken).not.toHaveProperty('externalIdToken'); }); diff --git a/apps/server/src/modules/authentication/interface/index.ts b/apps/server/src/modules/authentication/interface/index.ts index e5abc856509..a9de8109a5b 100644 --- a/apps/server/src/modules/authentication/interface/index.ts +++ b/apps/server/src/modules/authentication/interface/index.ts @@ -1 +1,2 @@ export * from './user'; +export * from './oauth-current-user'; diff --git a/apps/server/src/modules/authentication/interface/oauth-current-user.ts b/apps/server/src/modules/authentication/interface/oauth-current-user.ts new file mode 100644 index 00000000000..ddf15e1ca5d --- /dev/null +++ b/apps/server/src/modules/authentication/interface/oauth-current-user.ts @@ -0,0 +1,6 @@ +import { ICurrentUser } from './user'; + +export interface OauthCurrentUser extends ICurrentUser { + /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ + externalIdToken?: string; +} diff --git a/apps/server/src/modules/authentication/interface/user.ts b/apps/server/src/modules/authentication/interface/user.ts index cc8423f69b7..82b6d292d50 100644 --- a/apps/server/src/modules/authentication/interface/user.ts +++ b/apps/server/src/modules/authentication/interface/user.ts @@ -16,11 +16,6 @@ export interface ICurrentUser { /** True if a support member impersonates the user */ impersonated?: boolean; - /** True if the user is an external user e.g. an oauth user */ + /** True if the user is an external user e.g. an oauth user or ldap user */ isExternalUser: boolean; } - -export interface OauthCurrentUser extends ICurrentUser { - /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ - externalIdToken?: string; -} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index d06bea6d080..104ca1219a4 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -15,42 +15,78 @@ describe('CurrentUserMapper', () => { describe('userToICurrentUser', () => { describe('when mapping from a user entity to the current user object', () => { - it('should map with roles', () => { - const teacherRole = roleFactory.buildWithId({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] }); - const user = userFactory.buildWithId({ - roles: [teacherRole], - }); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ - accountId, - systemId: undefined, - roles: [teacherRole.id], - schoolId: null, + describe('when user has roles', () => { + const setup = () => { + const teacherRole = roleFactory.buildWithId({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT], + }); + const user = userFactory.buildWithId({ + roles: [teacherRole], + }); + + return { + teacherRole, + user, + }; + }; + + it('should map with roles', () => { + const { teacherRole, user } = setup(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, false); + + expect(currentUser).toMatchObject({ + accountId, + systemId: undefined, + roles: [teacherRole.id], + schoolId: null, + isExternalUser: false, + }); }); }); - it('should map without roles', () => { - const user = userFactory.buildWithId(); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ - accountId, - systemId: undefined, - roles: [], - schoolId: null, + describe('when user has no roles', () => { + it('should map without roles', () => { + const user = userFactory.buildWithId(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, true); + + expect(currentUser).toMatchObject({ + accountId, + systemId: undefined, + roles: [], + schoolId: null, + isExternalUser: true, + }); }); }); - it('should map system and school', () => { - const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(), - }); - const systemId = 'mockSystemId'; - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, systemId); - expect(currentUser).toMatchObject({ - accountId, - systemId, - roles: [], - schoolId: user.school.id, + describe('when systemId is provided', () => { + const setup = () => { + const user = userFactory.buildWithId({ + school: schoolFactory.buildWithId(), + }); + const systemId = 'mockSystemId'; + + return { + user, + systemId, + }; + }; + + it('should map system and school', () => { + const { user, systemId } = setup(); + + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(accountId, user, false, systemId); + + expect(currentUser).toMatchObject({ + accountId, + systemId, + roles: [], + schoolId: user.school.id, + isExternalUser: false, + }); }); }); }); diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts index ab832b70d8c..35dd6c5fe7c 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts @@ -6,14 +6,14 @@ import { ICurrentUser, OauthCurrentUser } from '../interface'; import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; export class CurrentUserMapper { - static userToICurrentUser(accountId: string, user: User, systemId?: string): ICurrentUser { + static userToICurrentUser(accountId: string, user: User, isExternalUser: boolean, systemId?: string): ICurrentUser { return { accountId, systemId, roles: user.roles.getItems().map((role: Role) => role.id), schoolId: user.school.id, userId: user.id, - isExternalUser: false, + isExternalUser, }; } diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index 78f445ce5b0..5b23be9eb74 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -436,7 +436,7 @@ describe('LdapStrategy', () => { schoolId: school.id, systemId: system.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); }); }); @@ -501,7 +501,7 @@ describe('LdapStrategy', () => { schoolId: school.id, systemId: system.id, accountId: account.id, - isExternalUser: false, + isExternalUser: true, }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index 1622e434310..6f33e92f21a 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -1,10 +1,10 @@ +import { AccountDto } from '@modules/account/services/dto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo, SystemEntity, User } from '@shared/domain'; import { LegacySchoolRepo, SystemRepo, UserRepo } from '@shared/repo'; import { ErrorLoggable } from '@src/core/error/loggable/error.loggable'; import { Logger } from '@src/core/logger'; -import { AccountDto } from '@modules/account/services/dto'; import { Strategy } from 'passport-custom'; import { LdapAuthorizationBodyParams } from '../controllers/dto'; import { ICurrentUser } from '../interface'; @@ -48,7 +48,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { await this.checkCredentials(account, system, ldapDn, password); - const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(account.id, user, systemId); + const currentUser: ICurrentUser = CurrentUserMapper.userToICurrentUser(account.id, user, true, systemId); return currentUser; } diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.ts b/apps/server/src/modules/authentication/strategy/local.strategy.ts index 28ed573b45e..1d31a86d833 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.ts @@ -39,7 +39,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { new Error(`login failing, because account ${account.id} has no userId`) ); const user = await this.userRepo.findById(accountUserId, true); - const currentUser = CurrentUserMapper.userToICurrentUser(account.id, user); + const currentUser = CurrentUserMapper.userToICurrentUser(account.id, user, false); return currentUser; } From cf3ce9ceb3181ecb6dcd4f04f06689f487410b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Thu, 9 Nov 2023 08:16:17 +0100 Subject: [PATCH 03/13] N21-726 Add location to school name during provisioning (#4532) --- .../provisioning/dto/external-school.dto.ts | 3 + .../service/oidc-provisioning.service.spec.ts | 475 +++++++++++++++--- .../oidc/service/oidc-provisioning.service.ts | 23 +- .../strategy/sanis/response/index.ts | 1 + .../response/sanis-anschrift-response.ts | 9 + .../response/sanis-organisation-response.ts | 4 + .../sanis/sanis-response.mapper.spec.ts | 6 +- .../strategy/sanis/sanis-response.mapper.ts | 3 +- 8 files changed, 434 insertions(+), 90 deletions(-) create mode 100644 apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts diff --git a/apps/server/src/modules/provisioning/dto/external-school.dto.ts b/apps/server/src/modules/provisioning/dto/external-school.dto.ts index c853c090228..701ee63f931 100644 --- a/apps/server/src/modules/provisioning/dto/external-school.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-school.dto.ts @@ -5,9 +5,12 @@ export class ExternalSchoolDto { officialSchoolNumber?: string; + location?: string; + constructor(props: ExternalSchoolDto) { this.externalId = props.externalId; this.name = props.name; this.officialSchoolNumber = props.officialSchoolNumber; + this.location = props.location; } } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts index 09e253dddbf..5fcd5fc37a5 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts @@ -1,28 +1,29 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountService } from '@modules/account/services/account.service'; +import { AccountSaveDto } from '@modules/account/services/dto'; +import { Group, GroupService } from '@modules/group'; +import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { RoleService } from '@modules/role'; +import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { UserService } from '@modules/user'; import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalSource, LegacySchoolDo, RoleName, RoleReference, SchoolFeatures } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { externalGroupDtoFactory, federalStateFactory, groupFactory, - roleDtoFactory, legacySchoolDoFactory, + roleDtoFactory, + roleFactory, schoolYearFactory, userDoFactory, - roleFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountSaveDto } from '@modules/account/services/dto'; -import { Group, GroupService } from '@modules/group'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; -import { UserService } from '@modules/user'; import CryptoJS from 'crypto-js'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; import { OidcProvisioningService } from './oidc-provisioning.service'; @@ -101,102 +102,414 @@ describe('OidcProvisioningService', () => { }); describe('provisionExternalSchool', () => { - const setup = () => { - const systemId = 'systemId'; - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + describe('when systemId is given and external school does not exist', () => { + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should save the correct data', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); + + it('should save the new school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [systemId], - features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); }); - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(null); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYearFactory.build()); - federalStateService.findFederalStateByName.mockResolvedValue(federalStateFactory.build()); + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); - describe('when systemId is given and external school does not exist', () => { - it('should save the new school', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - expect(result).toEqual(savedSchoolDO); + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); }); }); describe('when external school already exist', () => { - it('should update the existing school', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + it('should update the existing school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - expect(result).toEqual(savedSchoolDO); + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); }); - it('should append the new system', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); - const otherSystemId = 'otherSystemId'; - existingSchoolDO.systems = [otherSystemId]; + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); - await service.provisionExternalSchool(externalSchoolDto, systemId); + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - expect(schoolService.save).toHaveBeenCalledWith( - { - ...savedSchoolDO, - systems: [otherSystemId, systemId], - }, - true - ); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); }); - it('should create a new system list', async () => { - const { systemId, externalSchoolDto, existingSchoolDO, savedSchoolDO } = setup(); - existingSchoolDO.systems = undefined; + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - await service.provisionExternalSchool(externalSchoolDto, systemId); + await service.provisionExternalSchool(externalSchoolDto, systemId); - expect(schoolService.save).toHaveBeenCalledWith( - { - ...savedSchoolDO, - federalState: { - ...savedSchoolDO.federalState, - createdAt: expect.any(Date), - updatedAt: expect.any(Date), + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); + }); + + describe('when there is a system at the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const otherSystemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [otherSystemId, systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [otherSystemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + otherSystemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append the new system', async () => { + const { systemId, otherSystemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith( + { + ...savedSchoolDO, + systems: [otherSystemId, systemId], }, - inMaintenanceSince: expect.any(Date), - }, - true - ); + true + ); + }); + }); + + describe('when there is no system at the school yet', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [], + features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should create a new system list', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts index 66c243e6457..6d13537439b 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts @@ -1,7 +1,3 @@ -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, ExternalSource, FederalStateEntity, SchoolFeatures, SchoolYearEntity } from '@shared/domain'; -import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { Logger } from '@src/core/logger'; import { AccountService } from '@modules/account/services/account.service'; import { AccountSaveDto } from '@modules/account/services/dto'; import { Group, GroupService, GroupUser } from '@modules/group'; @@ -10,9 +6,13 @@ import { FederalStateNames } from '@modules/legacy-school/types'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { EntityId, ExternalSource, FederalStateEntity, SchoolFeatures, SchoolYearEntity } from '@shared/domain'; +import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; +import { Logger } from '@src/core/logger'; import { ObjectId } from 'bson'; import CryptoJS from 'crypto-js'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @@ -37,7 +37,7 @@ export class OidcProvisioningService { let school: LegacySchoolDo; if (existingSchool) { school = existingSchool; - school.name = externalSchool.name; + school.name = this.getSchoolName(externalSchool); school.officialSchoolNumber = externalSchool.officialSchoolNumber ?? existingSchool.officialSchoolNumber; if (!school.systems) { school.systems = [systemId]; @@ -52,7 +52,7 @@ export class OidcProvisioningService { school = new LegacySchoolDo({ externalId: externalSchool.externalId, - name: externalSchool.name, + name: this.getSchoolName(externalSchool), officialSchoolNumber: externalSchool.officialSchoolNumber, systems: [systemId], features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], @@ -63,9 +63,18 @@ export class OidcProvisioningService { } const savedSchool: LegacySchoolDo = await this.schoolService.save(school, true); + return savedSchool; } + private getSchoolName(externalSchool: ExternalSchoolDto): string { + const schoolName: string = externalSchool.location + ? `${externalSchool.name} (${externalSchool.location})` + : externalSchool.name; + + return schoolName; + } + async provisionExternalUser(externalUser: ExternalUserDto, systemId: EntityId, schoolId?: string): Promise { let roleRefs: RoleReference[] | undefined; if (externalUser.roles) { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts index 56f70ad0f41..bb8cc6e8d07 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts @@ -11,3 +11,4 @@ export * from './sanis-personenkontext-response'; export * from './sanis-gruppenzugehoerigkeit-response'; export * from './sanis-person-response'; export * from './sanis-sonstige-gruppenzugehoerige-response'; +export * from './sanis-anschrift-response'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts new file mode 100644 index 00000000000..6b793ba4486 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts @@ -0,0 +1,9 @@ +export interface SanisAnschriftResponse { + adresszeile?: string; + + postleitzahl?: string; + + ort?: string; + + ortsteil?: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts index 258cde00a50..fa7d2846ad1 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts @@ -1,3 +1,5 @@ +import { SanisAnschriftResponse } from './sanis-anschrift-response'; + export interface SanisOrganisationResponse { id: string; @@ -6,4 +8,6 @@ export interface SanisOrganisationResponse { name: string; typ: string; + + anschrift?: SanisAnschriftResponse; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index 2fe68c0163b..9829e3cb930 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; +import { GroupTypes } from '@modules/group'; import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { GroupTypes } from '@modules/group'; import { UUID } from 'bson'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { @@ -56,6 +56,9 @@ describe('SanisResponseMapper', () => { name: 'schoolName', typ: 'SCHULE', kennung: 'NI_123456_NI_ashd3838', + anschrift: { + ort: 'Hannover', + }, }, personenstatus: '', gruppen: [ @@ -103,6 +106,7 @@ describe('SanisResponseMapper', () => { externalId: externalSchoolId, name: 'schoolName', officialSchoolNumber: '123456_NI_ashd3838', + location: 'Hannover', }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 23d9b15fbdc..ee912dd67b5 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -1,7 +1,7 @@ +import { GroupTypes } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { RoleName } from '@shared/domain'; import { Logger } from '@src/core/logger'; -import { GroupTypes } from '@modules/group'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { GroupRoleUnknownLoggable } from '../../loggable'; import { @@ -45,6 +45,7 @@ export class SanisResponseMapper { name: source.personenkontexte[0].organisation.name, externalId: source.personenkontexte[0].organisation.id.toString(), officialSchoolNumber, + location: source.personenkontexte[0].organisation.anschrift?.ort, }); return mapped; From 96b199f9a70c96bef259bf77a4879702b94f1b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Thu, 9 Nov 2023 09:07:49 +0100 Subject: [PATCH 04/13] fix optional parameter with regex (#4536) --- .../common-tool-validation.service.spec.ts | 36 +++++++++++++++++++ .../service/common-tool-validation.service.ts | 2 +- .../external-tool-validation.service.ts | 4 +-- 3 files changed, 38 insertions(+), 4 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 43a2aba1a39..d3ca547a854 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 @@ -597,6 +597,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const undefinedRegex: CustomParameter = customParameterFactory.build({ name: 'undefinedRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: undefined, @@ -629,6 +630,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: '[x]', @@ -661,6 +663,7 @@ describe('CommonToolValidationService', () => { const setup = () => { const validRegex: CustomParameter = customParameterFactory.build({ name: 'validRegex', + isOptional: false, scope: CustomParameterScope.SCHOOL, type: CustomParameterType.STRING, regex: '[x]', @@ -688,6 +691,39 @@ describe('CommonToolValidationService', () => { expect(func).toThrowError('tool_param_value_regex'); }); }); + + describe('when parameter is optional and a regex is given, but the param value is undefined', () => { + const setup = () => { + const optionalRegex: CustomParameter = customParameterFactory.build({ + name: 'optionalRegex', + isOptional: true, + scope: CustomParameterScope.SCHOOL, + type: CustomParameterType.STRING, + regex: '[x]', + }); + const { externalTool, schoolExternalTool } = createTools( + { + parameters: [optionalRegex], + }, + { + parameters: [{ name: 'optionalRegex', value: undefined }], + } + ); + + return { + externalTool, + schoolExternalTool, + }; + }; + + it('should return without error', () => { + const { externalTool, schoolExternalTool } = setup(); + + const func = () => service.checkCustomParameterEntries(externalTool, schoolExternalTool); + + expect(func).not.toThrowError('tool_param_value_regex'); + }); + }); }); }); }); 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 9d315a97f34..3ee9ee7d465 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 @@ -87,7 +87,7 @@ export class CommonToolValidationService { } private checkParameterRegex(foundEntry: CustomParameterEntry, param: CustomParameter): void { - if (param.regex && !new RegExp(param.regex).test(foundEntry.value ?? '')) { + if (foundEntry.value !== undefined && param.regex && !new RegExp(param.regex).test(foundEntry.value ?? '')) { throw new ValidationError( `tool_param_value_regex: The given entry for the parameter with name ${foundEntry.name} does not fit the regex.` ); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts index 434e7fac86e..bf768a83a3e 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; -import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { ExternalTool } from '../domain'; import { ExternalToolLogoService } from './external-tool-logo.service'; import { ExternalToolParameterValidationService } from './external-tool-parameter-validation.service'; @@ -11,7 +10,6 @@ export class ExternalToolValidationService { constructor( private readonly externalToolService: ExternalToolService, private readonly externalToolParameterValidationService: ExternalToolParameterValidationService, - @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, private readonly externalToolLogoService: ExternalToolLogoService ) {} From 59ca98c6811e71f18383479742a742b954d478e9 Mon Sep 17 00:00:00 2001 From: Max Bischof <106820326+bischofmax@users.noreply.github.com> Date: Thu, 9 Nov 2023 12:28:26 +0100 Subject: [PATCH 05/13] BC-5702 - Add DecodeHtmlEntities decorator to caption and alternatveText response (#4528) --- .../board/controller/dto/element/file-element.response.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/modules/board/controller/dto/element/file-element.response.ts b/apps/server/src/modules/board/controller/dto/element/file-element.response.ts index 0fa23a7f735..d6c5122358f 100644 --- a/apps/server/src/modules/board/controller/dto/element/file-element.response.ts +++ b/apps/server/src/modules/board/controller/dto/element/file-element.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { DecodeHtmlEntities } from '@shared/controller'; import { ContentElementType } from '@shared/domain'; import { TimestampsResponse } from '../timestamps.response'; @@ -9,9 +10,11 @@ export class FileElementContent { } @ApiProperty() + @DecodeHtmlEntities() caption: string; @ApiProperty() + @DecodeHtmlEntities() alternativeText: string; } From a7273a1394ddb02f51cc4b11d89af4b9b2281d2e Mon Sep 17 00:00:00 2001 From: Max Bischof <106820326+bischofmax@users.noreply.github.com> Date: Thu, 9 Nov 2023 15:08:04 +0100 Subject: [PATCH 06/13] BC-5727 - Use etag to use preview cache (#4537) --- .../files-storage-preview.api.spec.ts | 111 +++++++++++++----- .../controller/files-storage.controller.ts | 14 ++- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index f63eef6ac68..6ccc6d0d5ea 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -1,4 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { PreviewProducer } from '@infra/preview-generator'; +import { S3ClientAdapter } from '@infra/s3-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ICurrentUser } from '@modules/authentication'; import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; @@ -6,9 +9,6 @@ import { ExecutionContext, INestApplication, NotFoundException, StreamableFile } import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { EntityId, Permission } from '@shared/domain'; -import { AntivirusService } from '@infra/antivirus'; -import { PreviewProducer } from '@infra/preview-generator'; -import { S3ClientAdapter } from '@infra/s3-client'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; import NodeClam from 'clamscan'; import { Request } from 'express'; @@ -63,6 +63,19 @@ class API { }; } + async getPreviewWithEtag(routeName: string, etag: string, query?: string | Record) { + const response = await request(this.app.getHttpServer()) + .get(routeName) + .query(query || {}) + .set('If-None-Match', etag); + + return { + result: response.body as StreamableFile, + error: response.body as ApiValidationError, + status: response.status, + }; + } + async getPreviewBytesRange(routeName: string, bytesRange: string, query?: string | Record) { const response = await request(this.app.getHttpServer()) .get(routeName) @@ -299,34 +312,75 @@ describe('File Controller (API) - preview', () => { return { uploadedFile }; }; - it('should return status 200 for successful download', async () => { - const { uploadedFile } = await setup(); - const query = { - ...defaultQueryParameters, - forceUpdate: false, - }; - - const response = await api.getPreview(`/file/preview/${uploadedFile.id}/${uploadedFile.name}`, query); - - expect(response.status).toEqual(200); + describe('WHEN header contains no etag', () => { + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + + const response = await api.getPreview(`/file/preview/${uploadedFile.id}/${uploadedFile.name}`, query); + + expect(response.status).toEqual(200); + }); + + it('should return status 206 and required headers for the successful partial file stream download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + + const response = await api.getPreviewBytesRange( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + 'bytes=0-', + query + ); + + expect(response.status).toEqual(206); + expect(response.headers['accept-ranges']).toMatch('bytes'); + expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + expect(response.headers.etag).toMatch('testTag'); + }); }); - it('should return status 206 and required headers for the successful partial file stream download', async () => { - const { uploadedFile } = await setup(); - const query = { - ...defaultQueryParameters, - forceUpdate: false, - }; - - const response = await api.getPreviewBytesRange( - `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, - 'bytes=0-', - query - ); + describe('WHEN header contains not matching etag', () => { + it('should return status 200 for successful download', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + const etag = 'otherTag'; + + const response = await api.getPreviewWithEtag( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + etag, + query + ); + + expect(response.status).toEqual(200); + }); + }); - expect(response.status).toEqual(206); - expect(response.headers['accept-ranges']).toMatch('bytes'); - expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + describe('WHEN header contains matching etag', () => { + it('should return status 304', async () => { + const { uploadedFile } = await setup(); + const query = { + ...defaultQueryParameters, + forceUpdate: false, + }; + const etag = 'testTag'; + + const response = await api.getPreviewWithEtag( + `/file/preview/${uploadedFile.id}/${uploadedFile.name}`, + etag, + query + ); + + expect(response.status).toEqual(304); + }); }); }); @@ -369,6 +423,7 @@ describe('File Controller (API) - preview', () => { expect(response.status).toEqual(206); expect(response.headers['accept-ranges']).toMatch('bytes'); expect(response.headers['content-range']).toMatch('bytes 0-3/4'); + expect(response.headers.etag).toMatch('testTag'); }); }); }); diff --git a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts index 736d69e3e29..7269336c44c 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts @@ -120,6 +120,7 @@ export class FilesStorageController { @ApiOperation({ summary: 'Streamable download of a preview file.' }) @ApiResponse({ status: 200, type: StreamableFile }) @ApiResponse({ status: 206, type: StreamableFile }) + @ApiResponse({ status: 304, description: 'Not Modified' }) @ApiResponse({ status: 400, type: ApiValidationError }) @ApiResponse({ status: 403, type: ForbiddenException }) @ApiResponse({ status: 404, type: NotFoundException }) @@ -134,8 +135,9 @@ export class FilesStorageController { @Query() previewParams: PreviewParams, @Req() req: Request, @Res({ passthrough: true }) response: Response, - @Headers('Range') bytesRange?: string - ): Promise { + @Headers('Range') bytesRange?: string, + @Headers('If-None-Match') etag?: string + ): Promise { const fileResponse = await this.filesStorageUC.downloadPreview( currentUser.userId, params, @@ -143,6 +145,14 @@ export class FilesStorageController { bytesRange ); + response.set({ ETag: fileResponse.etag }); + + if (etag === fileResponse.etag) { + response.status(HttpStatus.NOT_MODIFIED); + + return undefined; + } + const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange); return streamableFile; From f81f023ddff2194b5154e58a511f271022df33a4 Mon Sep 17 00:00:00 2001 From: sszafGCA <116172610+sszafGCA@users.noreply.github.com> Date: Thu, 9 Nov 2023 16:52:31 +0100 Subject: [PATCH 07/13] BC-4895-stop sending emails to non existent thr @schul-cloud.org addresses (#4476) * impl of mail addresses whitelist logic --- .../src/infra/mail/interfaces/mail-config.ts | 3 ++ apps/server/src/infra/mail/mail.module.ts | 3 ++ .../src/infra/mail/mail.service.spec.ts | 49 ++++++++++++++++--- apps/server/src/infra/mail/mail.service.ts | 42 ++++++++++++++-- .../src/modules/server/server.config.ts | 7 ++- config/default.schema.json | 1 + 6 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/infra/mail/interfaces/mail-config.ts diff --git a/apps/server/src/infra/mail/interfaces/mail-config.ts b/apps/server/src/infra/mail/interfaces/mail-config.ts new file mode 100644 index 00000000000..6dbb0c7864d --- /dev/null +++ b/apps/server/src/infra/mail/interfaces/mail-config.ts @@ -0,0 +1,3 @@ +export interface IMailConfig { + ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: string[]; +} diff --git a/apps/server/src/infra/mail/mail.module.ts b/apps/server/src/infra/mail/mail.module.ts index 1ca0630c44f..ee6d50d59e7 100644 --- a/apps/server/src/infra/mail/mail.module.ts +++ b/apps/server/src/infra/mail/mail.module.ts @@ -1,5 +1,7 @@ import { Module, DynamicModule } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { MailService } from './mail.service'; +import { IMailConfig } from './interfaces/mail-config'; interface MailModuleOptions { exchange: string; @@ -17,6 +19,7 @@ export class MailModule { provide: 'MAIL_SERVICE_OPTIONS', useValue: { exchange: options.exchange, routingKey: options.routingKey }, }, + ConfigService, ], exports: [MailService], }; diff --git a/apps/server/src/infra/mail/mail.service.spec.ts b/apps/server/src/infra/mail/mail.service.spec.ts index 58c0ce9336a..ebc77030252 100644 --- a/apps/server/src/infra/mail/mail.service.spec.ts +++ b/apps/server/src/infra/mail/mail.service.spec.ts @@ -1,7 +1,10 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; import { Mail } from './mail.interface'; import { MailService } from './mail.service'; +import { IMailConfig } from './interfaces/mail-config'; describe('MailService', () => { let module: TestingModule; @@ -19,6 +22,10 @@ describe('MailService', () => { MailService, { provide: AmqpConnection, useValue: { publish: () => {} } }, { provide: 'MAIL_SERVICE_OPTIONS', useValue: mailServiceOptions }, + { + provide: ConfigService, + useValue: createMock>({ get: () => ['schul-cloud.org', 'example.com'] }), + }, ], }).compile(); @@ -34,13 +41,43 @@ describe('MailService', () => { expect(service).toBeDefined(); }); - it('should send given data to queue', async () => { - const data: Mail = { mail: { plainTextContent: 'content', subject: 'Test' }, recipients: ['test@example.com'] }; - const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); + describe('send', () => { + describe('when recipients array is empty', () => { + it('should not send email', async () => { + const data: Mail = { + mail: { plainTextContent: 'content', subject: 'Test' }, + recipients: ['test@schul-cloud.org'], + }; - await service.send(data); + const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); - const expectedParams = [mailServiceOptions.exchange, mailServiceOptions.routingKey, data, { persistent: true }]; - expect(amqpConnectionSpy).toHaveBeenCalledWith(...expectedParams); + await service.send(data); + + expect(amqpConnectionSpy).toHaveBeenCalledTimes(0); + }); + }); + describe('when sending email', () => { + it('should remove email address that have blacklisted domain and send given data to queue', async () => { + const data: Mail = { + mail: { plainTextContent: 'content', subject: 'Test' }, + recipients: ['test@schul-cloud.org', 'test@example1.com', 'test2@schul-cloud.org', 'test3@schul-cloud.org'], + cc: ['test@example.com', 'test5@schul-cloud.org', 'test6@schul-cloud.org'], + bcc: ['test7@schul-cloud.org', 'test@example2.com', 'test8@schul-cloud.org'], + replyTo: ['test@example3.com', 'test9@schul-cloud.org', 'test10@schul-cloud.org'], + }; + + const amqpConnectionSpy = jest.spyOn(amqpConnection, 'publish'); + + await service.send(data); + + expect(data.recipients).toEqual(['test@example1.com']); + expect(data.cc).toEqual([]); + expect(data.bcc).toEqual(['test@example2.com']); + expect(data.replyTo).toEqual(['test@example3.com']); + + const expectedParams = [mailServiceOptions.exchange, mailServiceOptions.routingKey, data, { persistent: true }]; + expect(amqpConnectionSpy).toHaveBeenCalledWith(...expectedParams); + }); + }); }); }); diff --git a/apps/server/src/infra/mail/mail.service.ts b/apps/server/src/infra/mail/mail.service.ts index aaf9cfacb9d..432f0746934 100644 --- a/apps/server/src/infra/mail/mail.service.ts +++ b/apps/server/src/infra/mail/mail.service.ts @@ -1,7 +1,8 @@ import { AmqpConnection } from '@golevelup/nestjs-rabbitmq'; import { Inject, Injectable } from '@nestjs/common'; - +import { ConfigService } from '@nestjs/config'; import { Mail } from './mail.interface'; +import { IMailConfig } from './interfaces/mail-config'; interface MailServiceOptions { exchange: string; @@ -10,12 +11,47 @@ interface MailServiceOptions { @Injectable() export class MailService { + private readonly domainBlacklist: string[]; + constructor( private readonly amqpConnection: AmqpConnection, - @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions - ) {} + @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions, + private readonly configService: ConfigService + ) { + this.domainBlacklist = this.configService.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS'); + } public async send(data: Mail): Promise { + if (this.domainBlacklist.length > 0) { + data.recipients = this.filterEmailAdresses(data.recipients) as string[]; + data.cc = this.filterEmailAdresses(data.cc); + data.bcc = this.filterEmailAdresses(data.bcc); + data.replyTo = this.filterEmailAdresses(data.replyTo); + } + + if (data.recipients.length === 0) { + return; + } + await this.amqpConnection.publish(this.options.exchange, this.options.routingKey, data, { persistent: true }); } + + private filterEmailAdresses(mails: string[] | undefined): string[] | undefined { + if (mails === undefined || mails === null) { + return mails; + } + const mailWhitelist: string[] = []; + + for (const mail of mails) { + const mailDomain = this.getMailDomain(mail); + if (mailDomain && !this.domainBlacklist.includes(mailDomain)) { + mailWhitelist.push(mail); + } + } + return mailWhitelist; + } + + private getMailDomain(mail: string): string { + return mail.split('@')[1]; + } } diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index becc7ec78fc..5d1ea95cc3b 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -5,6 +5,7 @@ import type { IAccountConfig } from '@modules/account'; import type { IFilesStorageClientConfig } from '@modules/files-storage-client'; import type { IUserConfig } from '@modules/user'; import type { ICommonCartridgeConfig } from '@modules/learnroom/common-cartridge'; +import { IMailConfig } from '@src/infra/mail/interfaces/mail-config'; export enum NodeEnvType { TEST = 'test', @@ -19,7 +20,8 @@ export interface IServerConfig IFilesStorageClientConfig, IAccountConfig, IIdentityManagementConfig, - ICommonCartridgeConfig { + ICommonCartridgeConfig, + IMailConfig { NODE_ENV: string; SC_DOMAIN: string; } @@ -39,6 +41,9 @@ const config: IServerConfig = { FEATURE_IDENTITY_MANAGEMENT_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean, + ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: (Configuration.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS') as string) + .split(',') + .map((domain) => domain.trim()), }; export const serverConfig = () => config; diff --git a/config/default.schema.json b/config/default.schema.json index a34d8e899ad..bd637b719e9 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -177,6 +177,7 @@ }, "ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS": { "type": "string", + "default":"", "description": "Add custom domain to the list of blocked domains (comma separated list)." }, "FEATURE_TSP_AUTO_CONSENT_ENABLED": { From 4a7cb7174cd4dd97eb3d4a180608024a37de34f4 Mon Sep 17 00:00:00 2001 From: Cedric Evers <12080057+CeEv@users.noreply.github.com> Date: Fri, 10 Nov 2023 10:43:51 +0100 Subject: [PATCH 08/13] BC-5662 Add linter rule for deep module imports (#4538) --- .eslintrc.js | 29 +- apps/server/src/apps/server.app.ts | 2 +- apps/server/src/modules/account/index.ts | 2 +- .../src/modules/account/services/index.ts | 1 + .../modules/user/service/user.service.spec.ts | 9 +- package-lock.json | 1228 +++++++++++------ package.json | 2 +- 7 files changed, 873 insertions(+), 400 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 008320274fb..a1851003d0d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -65,22 +65,24 @@ module.exports = { overrides: [ { files: ['apps/**/*.ts'], + env: { + node: true, + es6: true, + }, parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint/eslint-plugin'], + parserOptions: { + project: 'apps/server/tsconfig.lint.json', + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin', 'import'], extends: [ 'airbnb-typescript/base', 'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended-requiring-type-checking', 'prettier', 'plugin:promise/recommended', + 'plugin:import/typescript', ], - parserOptions: { - project: 'apps/server/tsconfig.lint.json', - }, - env: { - node: true, - es6: true, - }, rules: { 'import/no-unresolved': 'off', // better handled by ts resolver 'import/no-extraneous-dependencies': 'off', // better handles by ts resolver @@ -98,6 +100,17 @@ module.exports = { allowSingleExtends: true, }, ], + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['@infra/*/*', '@modules/*/*', '!*.module'], + message: 'Do not deep import from a module', + }, + ], + }, + ], }, overrides: [ { diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 9538d6c8515..6322bcd568f 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -7,7 +7,7 @@ import { ExpressAdapter } from '@nestjs/platform-express'; import { enableOpenApiDocs } from '@shared/controller/swagger'; import { Mail, MailService } from '@infra/mail'; import { LegacyLogger, Logger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; +import { AccountService } from '@modules/account'; import { TeamService } from '@modules/teams/service/team.service'; import { AccountValidationService } from '@modules/account/services/account.validation.service'; import { AccountUc } from '@modules/account/uc/account.uc'; diff --git a/apps/server/src/modules/account/index.ts b/apps/server/src/modules/account/index.ts index 2fa0bcc2334..7db4a66a657 100644 --- a/apps/server/src/modules/account/index.ts +++ b/apps/server/src/modules/account/index.ts @@ -1,3 +1,3 @@ export * from './account.module'; export * from './account-config'; -export { AccountService } from './services'; +export { AccountService, AccountDto } from './services'; diff --git a/apps/server/src/modules/account/services/index.ts b/apps/server/src/modules/account/services/index.ts index 72778be1f1e..2fc3f3a3246 100644 --- a/apps/server/src/modules/account/services/index.ts +++ b/apps/server/src/modules/account/services/index.ts @@ -1 +1,2 @@ export * from './account.service'; +export { AccountDto } from './dto'; diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index 223fa1f0c88..f65d02c13a5 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -7,12 +7,11 @@ import { UserDO } from '@shared/domain/domainobject/user.do'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; -import { RoleService } from '@modules/role/service/role.service'; -import { UserService } from '@modules/user/service/user.service'; -import { UserDto } from '@modules/user/uc/dto/user.dto'; +import { AccountService, AccountDto } from '@modules/account'; +import { RoleService } from '@modules/role'; import { OauthCurrentUser } from '@modules/authentication/interface'; +import { UserDto } from '../uc/dto/user.dto'; +import { UserService } from './user.service'; import { UserQuery } from './user-query.type'; describe('UserService', () => { diff --git a/package-lock.json b/package-lock.json index 5f895871520..bf63523c91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -178,7 +178,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-jest": "^27.1.6", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-no-only-tests": "^3.1.0", @@ -6866,21 +6866,33 @@ "node": ">=6.0" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" }, "engines": { @@ -6914,15 +6926,73 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", - "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -7750,12 +7820,13 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9283,6 +9354,19 @@ "node": ">=0.8" } }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -9293,10 +9377,11 @@ } }, "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -9708,35 +9793,49 @@ } }, "node_modules/es-abstract": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.5.tgz", - "integrity": "sha512-7h8MM2EQhsCA7pU/Nv78qOXFpD8Rhqd12gYiSJVkrH9+e8VuA8JlPJK/hQjjlLv6pJvx/z1iRFKzYb0XT/RuAQ==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "dependencies": { - "call-bind": "^1.0.2", + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "unbox-primitive": "^1.0.2" + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -9751,6 +9850,28 @@ "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", "dev": true }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, "node_modules/es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -10441,13 +10562,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "dependencies": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -10533,16 +10655,20 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", "dev": true, "dependencies": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "engines": { "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/eslint-module-utils/node_modules/debug": { @@ -10555,24 +10681,28 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "engines": { "node": ">=4" @@ -10582,12 +10712,12 @@ } }, "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { @@ -10614,11 +10744,14 @@ "json5": "lib/cli.js" } }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } }, "node_modules/eslint-plugin-import/node_modules/strip-bom": { "version": "3.0.0", @@ -10630,13 +10763,13 @@ } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -11795,18 +11928,6 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, - "node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dev": true, - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/fishery": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", @@ -12149,19 +12270,22 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -12233,13 +12357,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12355,6 +12480,20 @@ "node": ">=4" } }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -12515,6 +12654,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -12558,6 +12698,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", @@ -12615,6 +12766,17 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -12987,12 +13149,12 @@ "dev": true }, "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" }, "engines": { @@ -13052,6 +13214,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -13129,11 +13304,11 @@ } }, "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dependencies": { - "has": "^1.0.3" + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13372,15 +13547,11 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" }, "engines": { "node": ">= 0.4" @@ -16083,19 +16254,6 @@ "node": ">=6.11.5" } }, - "node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dev": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -18632,9 +18790,9 @@ } }, "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -18678,15 +18836,44 @@ "node": ">= 0.4" } }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, "node_modules/object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "engines": { "node": ">= 0.4" @@ -18995,30 +19182,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -19031,15 +19194,6 @@ "node": ">=8" } }, - "node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -19207,15 +19361,6 @@ "node": ">= 0.4.0" } }, - "node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -20616,13 +20761,13 @@ "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" }, "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" }, "engines": { "node": ">= 0.4" @@ -20858,11 +21003,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dependencies": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -21524,6 +21669,28 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -21833,6 +22000,33 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -22420,27 +22614,43 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "dependencies": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -23803,6 +24013,67 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -24388,16 +24659,15 @@ "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" }, "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "dependencies": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -29984,21 +30254,30 @@ "@babel/runtime-corejs3": "^7.10.2" } }, + "array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "requires": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", "is-string": "^1.0.7" } }, @@ -30023,15 +30302,55 @@ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" }, + "array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + } + }, "array.prototype.flat": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz", - "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + } + }, + "arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "requires": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" } }, "asap": { @@ -30702,12 +31021,13 @@ } }, "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" } }, "call-me-maybe": { @@ -31898,6 +32218,16 @@ } } }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -31905,10 +32235,11 @@ "dev": true }, "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } @@ -32243,35 +32574,49 @@ } }, "es-abstract": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.5.tgz", - "integrity": "sha512-7h8MM2EQhsCA7pU/Nv78qOXFpD8Rhqd12gYiSJVkrH9+e8VuA8JlPJK/hQjjlLv6pJvx/z1iRFKzYb0XT/RuAQ==", + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", "requires": { - "call-bind": "^1.0.2", + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has": "^1.0.3", "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", "is-callable": "^1.2.7", "is-negative-zero": "^2.0.2", "is-regex": "^1.1.4", "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", + "object-inspect": "^1.13.1", "object-keys": "^1.1.1", "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "unbox-primitive": "^1.0.2" + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" } }, "es-module-lexer": { @@ -32280,6 +32625,25 @@ "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", "dev": true }, + "es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "requires": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + } + }, + "es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, "es-to-primitive": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", @@ -32842,13 +33206,14 @@ "requires": {} }, "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "requires": { "debug": "^3.2.7", - "resolve": "^1.20.0" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" }, "dependencies": { "debug": { @@ -32908,13 +33273,12 @@ } }, "eslint-module-utils": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz", - "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", "dev": true, "requires": { - "debug": "^3.2.7", - "find-up": "^2.1.0" + "debug": "^3.2.7" }, "dependencies": { "debug": { @@ -32929,33 +33293,37 @@ } }, "eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", + "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, "requires": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.14.2" }, "dependencies": { "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "requires": { - "ms": "2.0.0" + "ms": "^2.1.1" } }, "doctrine": { @@ -32976,10 +33344,10 @@ "minimist": "^1.2.0" } }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true }, "strip-bom": { @@ -32989,13 +33357,13 @@ "dev": true }, "tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, "requires": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -33721,15 +34089,6 @@ "pkg-dir": "^4.1.0" } }, - "find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", - "dev": true, - "requires": { - "locate-path": "^2.0.0" - } - }, "fishery": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", @@ -33975,19 +34334,19 @@ } }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" } }, "functional-red-black-tree": { @@ -34038,13 +34397,14 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "get-package-type": { @@ -34121,6 +34481,14 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "requires": { + "define-properties": "^1.1.3" + } + }, "globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -34256,6 +34624,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -34284,6 +34653,11 @@ "get-intrinsic": "^1.1.1" } }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" + }, "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", @@ -34322,6 +34696,14 @@ } } }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -34601,12 +34983,12 @@ } }, "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", "side-channel": "^1.0.4" } }, @@ -34645,6 +35027,16 @@ "has-tostringtag": "^1.0.0" } }, + "is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + } + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -34687,11 +35079,11 @@ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" }, "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "requires": { - "has": "^1.0.3" + "hasown": "^2.0.0" } }, "is-date-object": { @@ -34837,15 +35229,11 @@ } }, "is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "which-typed-array": "^1.1.11" } }, "is-typedarray": { @@ -36935,16 +37323,6 @@ "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", "dev": true }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", - "dev": true, - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -38968,9 +39346,9 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" }, "object-keys": { "version": "1.1.1", @@ -38999,15 +39377,38 @@ "es-abstract": "^1.19.1" } }, + "object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, + "object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, "object.values": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", - "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "on-headers": { @@ -39231,26 +39632,6 @@ "yocto-queue": "^0.1.0" } }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", - "dev": true, - "requires": { - "p-limit": "^1.1.0" - }, - "dependencies": { - "p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "requires": { - "p-try": "^1.0.0" - } - } - } - }, "p-map": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", @@ -39260,12 +39641,6 @@ "aggregate-error": "^3.0.0" } }, - "p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true - }, "package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -39388,12 +39763,6 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", - "dev": true - }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -40425,13 +40794,13 @@ "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" }, "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" } }, "regexpp": { @@ -40603,11 +40972,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "requires": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } @@ -41102,6 +41471,24 @@ "tslib": "^2.1.0" } }, + "safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + } + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -41352,6 +41739,27 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "requires": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + } + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -41838,24 +42246,34 @@ } } }, + "string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + } + }, "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" } }, "strip-ansi": { @@ -42855,6 +43273,49 @@ } } }, + "typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + } + }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -43325,16 +43786,15 @@ "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" }, "which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", "requires": { "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "call-bind": "^1.0.4", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" + "has-tostringtag": "^1.0.0" } }, "window-size": { diff --git a/package.json b/package.json index 6afd927c17c..53a0752ee10 100644 --- a/package.json +++ b/package.json @@ -261,7 +261,7 @@ "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.29.0", "eslint-plugin-jest": "^27.1.6", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-no-only-tests": "^3.1.0", From a407c719a8471a8a8823e2dfa2d94e1b452ccb34 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 10 Nov 2023 13:19:23 +0100 Subject: [PATCH 09/13] N21-1029 renaming of collections (#4547) * renames some collections: user_login_migrations -> user-login-migrations external_tools -> external-tools context_external_tools -> context-external-tools school_external_tools -> school-external-tools --- .../templates/configmap_file_init.yml.j2 | 12 ++--- .../allowed-authorization-object-type.enum.ts | 4 +- .../entity/context-external-tool.entity.ts | 2 +- .../entity/external-tool.entity.ts | 2 +- .../entity/school-external-tool.entity.ts | 2 +- .../entity/user-login-migration.entity.ts | 2 +- ...tools.json => context-external-tools.json} | 0 ...xternal_tools.json => external-tools.json} | 0 backup/setup/migrations.json | 11 +++++ ..._tools.json => school-external-tools.json} | 0 backup/setup/user-login-migrations.json | 1 + ...tool-and-user-login-migration-renamings.js | 44 +++++++++++++++++++ src/services/school/model.js | 2 +- 13 files changed, 69 insertions(+), 13 deletions(-) rename backup/setup/{context_external_tools.json => context-external-tools.json} (100%) rename backup/setup/{external_tools.json => external-tools.json} (100%) rename backup/setup/{school_external_tools.json => school-external-tools.json} (100%) create mode 100644 backup/setup/user-login-migrations.json create mode 100644 migrations/1699529266062-tool-and-user-login-migration-renamings.js diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index a3e5459077f..654d4152b95 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -390,8 +390,8 @@ data: }' # Add Bettermarks' tools configuration as an external tool - # (stored in the 'external_tools' collection) that uses OAuth. - mongosh $DATABASE__URL --eval 'db.external_tools.replaceOne( + # (stored in the 'external-tools' collection) that uses OAuth. + mongosh $DATABASE__URL --eval 'db.external-tools.replaceOne( { "name": "bettermarks", "config_type": "oauth2" @@ -486,9 +486,9 @@ data: echo "POSTed nextcloud to hydra." # Add Nextcloud' tools configuration as an external tool - # (stored in the 'external_tools' collection) that uses OAuth. - echo "Inserting nextcloud to external_tools..." - mongosh $DATABASE__URL --eval 'db.external_tools.update( + # (stored in the 'external-tools' collection) that uses OAuth. + echo "Inserting nextcloud to external-tools..." + mongosh $DATABASE__URL --eval 'db.external-tools.update( { "name": "nextcloud", "config_type": "oauth2" @@ -512,7 +512,7 @@ data: "upsert": true } );' - echo "Inserted nextcloud to external_tools." + echo "Inserted nextcloud to external-tools." echo "Nextcloud config data init performed successfully." fi diff --git a/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts b/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts index f36ca235af1..01f24b21985 100644 --- a/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts +++ b/apps/server/src/modules/authorization/domain/type/allowed-authorization-object-type.enum.ts @@ -7,7 +7,7 @@ export enum AuthorizableReferenceType { 'Lesson' = 'lessons', 'Team' = 'teams', 'Submission' = 'submissions', - 'SchoolExternalToolEntity' = 'school_external_tools', + 'SchoolExternalToolEntity' = 'school-external-tools', 'BoardNode' = 'boardnodes', - 'ContextExternalToolEntity' = 'context_external_tools', + 'ContextExternalToolEntity' = 'context-external-tools', } diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts index 6fdbad2b3af..aebab1a8d5d 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.ts @@ -18,7 +18,7 @@ export interface IContextExternalToolProperties { toolVersion: number; } -@Entity({ tableName: 'context_external_tools' }) +@Entity({ tableName: 'context-external-tools' }) export class ContextExternalToolEntity extends BaseEntityWithTimestamps { @ManyToOne() schoolTool: SchoolExternalToolEntity; diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index 3bd3ed9c30d..481ed3b7c2d 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -6,7 +6,7 @@ import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } export type IExternalToolProperties = Readonly>; -@Entity({ tableName: 'external_tools' }) +@Entity({ tableName: 'external-tools' }) export class ExternalToolEntity extends BaseEntityWithTimestamps { @Unique() @Property() diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts index a1682e3b7cd..fc7f6703d05 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.ts @@ -11,7 +11,7 @@ export interface ISchoolExternalToolProperties { toolVersion: number; } -@Entity({ tableName: 'school_external_tools' }) +@Entity({ tableName: 'school-external-tools' }) export class SchoolExternalToolEntity extends BaseEntityWithTimestamps { @ManyToOne() tool: ExternalToolEntity; diff --git a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts index 2daf9707f3c..b79b3d862c3 100644 --- a/apps/server/src/shared/domain/entity/user-login-migration.entity.ts +++ b/apps/server/src/shared/domain/entity/user-login-migration.entity.ts @@ -5,7 +5,7 @@ import { BaseEntityWithTimestamps } from './base.entity'; export type IUserLoginMigration = Readonly>; -@Entity({ tableName: 'user_login_migrations' }) +@Entity({ tableName: 'user-login-migrations' }) export class UserLoginMigrationEntity extends BaseEntityWithTimestamps { @OneToOne(() => SchoolEntity, undefined, { nullable: false }) school: SchoolEntity; diff --git a/backup/setup/context_external_tools.json b/backup/setup/context-external-tools.json similarity index 100% rename from backup/setup/context_external_tools.json rename to backup/setup/context-external-tools.json diff --git a/backup/setup/external_tools.json b/backup/setup/external-tools.json similarity index 100% rename from backup/setup/external_tools.json rename to backup/setup/external-tools.json diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 90fcee0baa3..e00ca430152 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -339,5 +339,16 @@ "$date": "2023-10-26T13:06:27.322Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "654cc2326b83f786c4227b21" + }, + "state": "up", + "name": "tool-and-user-login-migration-renamings", + "createdAt": { + "$date": "2023-11-09T11:27:46.062Z" + }, + "__v": 0 } ] diff --git a/backup/setup/school_external_tools.json b/backup/setup/school-external-tools.json similarity index 100% rename from backup/setup/school_external_tools.json rename to backup/setup/school-external-tools.json diff --git a/backup/setup/user-login-migrations.json b/backup/setup/user-login-migrations.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/backup/setup/user-login-migrations.json @@ -0,0 +1 @@ +[] diff --git a/migrations/1699529266062-tool-and-user-login-migration-renamings.js b/migrations/1699529266062-tool-and-user-login-migration-renamings.js new file mode 100644 index 00000000000..8b2480a41e4 --- /dev/null +++ b/migrations/1699529266062-tool-and-user-login-migration-renamings.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); +const { info, error } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +async function renameCollection(oldName, newName) { + try { + await mongoose.connection.collection(oldName).rename(newName); + info(`Renamed collection ${oldName} to ${newName}`); + } catch (err) { + error(`Error renaming collection ${oldName} to ${newName}: ${err.message}`); + throw err; + } +} + +module.exports = { + up: async function up() { + await connect(); + + await renameCollection('user_login_migrations', 'user-login-migrations'); + + await renameCollection('external_tools', 'external-tools'); + + await renameCollection('context_external_tools', 'context-external-tools'); + + await renameCollection('school_external_tools', 'school-external-tools'); + + await close(); + }, + + down: async function down() { + await connect(); + + await renameCollection('user-login-migrations', 'user_login_migrations'); + + await renameCollection('external-tools', 'external_tools'); + + await renameCollection('context-external-tools', 'context_external_tools'); + + await renameCollection('school-external-tools', 'school_external_tools'); + + await close(); + }, +}; diff --git a/src/services/school/model.js b/src/services/school/model.js index 0ec931e4191..787f7b55348 100644 --- a/src/services/school/model.js +++ b/src/services/school/model.js @@ -177,7 +177,7 @@ const gradeLevelSchema = new Schema({ }); const schoolModel = mongoose.model('school', schoolSchema); -const userLoginMigrationModel = mongoose.model('userLoginMigration', userLoginMigrationSchema, 'user_login_migrations'); +const userLoginMigrationModel = mongoose.model('userLoginMigration', userLoginMigrationSchema, 'user-login-migrations'); const schoolGroupModel = mongoose.model('schoolGroup', schoolGroupSchema); const yearModel = mongoose.model('year', yearSchema); const gradeLevelModel = mongoose.model('gradeLevel', gradeLevelSchema); From 249454c2773e5583ac2ea64b567d17b00767b47c Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 10 Nov 2023 15:55:39 +0100 Subject: [PATCH 10/13] N21-1029 bugfix renaming of collections (#4551) * changed rename to aggregate and drop --- ...tool-and-user-login-migration-renamings.js | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/migrations/1699529266062-tool-and-user-login-migration-renamings.js b/migrations/1699529266062-tool-and-user-login-migration-renamings.js index 8b2480a41e4..e1b0695b444 100644 --- a/migrations/1699529266062-tool-and-user-login-migration-renamings.js +++ b/migrations/1699529266062-tool-and-user-login-migration-renamings.js @@ -1,14 +1,23 @@ const mongoose = require('mongoose'); const { info, error } = require('../src/logger'); - const { connect, close } = require('../src/utils/database'); -async function renameCollection(oldName, newName) { +async function aggregateAndDropCollection(oldName, newName) { try { - await mongoose.connection.collection(oldName).rename(newName); - info(`Renamed collection ${oldName} to ${newName}`); + const { connection } = mongoose; + + // Aggregation pipeline for copying the documents + const pipeline = [{ $match: {} }, { $out: newName }]; + + // Copy documents from the old collection to the new collection + await connection.collection(oldName).aggregate(pipeline).toArray(); + info(`Aggregated and copied documents from ${oldName} to ${newName}`); + + // Delete old collection + await connection.collection(oldName).drop(); + info(`Dropped collection ${oldName}`); } catch (err) { - error(`Error renaming collection ${oldName} to ${newName}: ${err.message}`); + error(`Error aggregating, copying, and deleting collection ${oldName} to ${newName}: ${err.message}`); throw err; } } @@ -17,13 +26,10 @@ module.exports = { up: async function up() { await connect(); - await renameCollection('user_login_migrations', 'user-login-migrations'); - - await renameCollection('external_tools', 'external-tools'); - - await renameCollection('context_external_tools', 'context-external-tools'); - - await renameCollection('school_external_tools', 'school-external-tools'); + await aggregateAndDropCollection('user_login_migrations', 'user-login-migrations'); + await aggregateAndDropCollection('external_tools', 'external-tools'); + await aggregateAndDropCollection('context_external_tools', 'context-external-tools'); + await aggregateAndDropCollection('school_external_tools', 'school-external-tools'); await close(); }, @@ -31,13 +37,10 @@ module.exports = { down: async function down() { await connect(); - await renameCollection('user-login-migrations', 'user_login_migrations'); - - await renameCollection('external-tools', 'external_tools'); - - await renameCollection('context-external-tools', 'context_external_tools'); - - await renameCollection('school-external-tools', 'school_external_tools'); + await aggregateAndDropCollection('user-login-migrations', 'user_login_migrations'); + await aggregateAndDropCollection('external-tools', 'external_tools'); + await aggregateAndDropCollection('context-external-tools', 'context_external_tools'); + await aggregateAndDropCollection('school-external-tools', 'school_external_tools'); await close(); }, From 034cab840ab347aa621248cc47e1e7317631e4b7 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Mon, 13 Nov 2023 10:13:15 +0100 Subject: [PATCH 11/13] N21-1456 improves oauth error handling (#4539) --- .../loggable/axios-error.loggable.spec.ts | 32 ++ .../error/loggable/axios-error.loggable.ts | 20 + apps/server/src/core/error/loggable/index.ts | 2 + .../hydra/hydra.adapter.spec.ts | 391 +++++++++++++----- .../oauth-provider/hydra/hydra.adapter.ts | 37 +- ...ra-oauth-failed-loggable-exception.spec.ts | 35 ++ .../hydra-oauth-failed-loggable-exception.ts | 8 + .../infra/oauth-provider/loggable/index.ts | 1 + .../src/modules/oauth/loggable/index.ts | 1 + .../modules/oauth/loggable/oauth-sso.error.ts | 4 + .../token-request-loggable-exception.spec.ts | 34 ++ .../token-request-loggable-exception.ts | 8 + .../service/oauth-adapter.service.spec.ts | 61 ++- .../oauth/service/oauth-adapter.service.ts | 11 +- .../testing/factory/axios-error.factory.ts | 28 ++ .../src/shared/testing/factory/index.ts | 1 + 16 files changed, 549 insertions(+), 125 deletions(-) create mode 100644 apps/server/src/core/error/loggable/axios-error.loggable.spec.ts create mode 100644 apps/server/src/core/error/loggable/axios-error.loggable.ts create mode 100644 apps/server/src/core/error/loggable/index.ts create mode 100644 apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts create mode 100644 apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts create mode 100644 apps/server/src/infra/oauth-provider/loggable/index.ts create mode 100644 apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts create mode 100644 apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts create mode 100644 apps/server/src/shared/testing/factory/axios-error.factory.ts diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts new file mode 100644 index 00000000000..f2b480a4bf7 --- /dev/null +++ b/apps/server/src/core/error/loggable/axios-error.loggable.spec.ts @@ -0,0 +1,32 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { AxiosErrorLoggable } from './axios-error.loggable'; + +describe(AxiosErrorLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const type = 'mockType'; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + + const axiosErrorLoggable = new AxiosErrorLoggable(axiosError, type); + + return { axiosErrorLoggable, error, axiosError }; + }; + + it('should return error log message', () => { + const { axiosErrorLoggable, error, axiosError } = setup(); + + const result = axiosErrorLoggable.getLogMessage(); + + expect(result).toEqual({ + type: 'mockType', + message: axiosError.message, + data: JSON.stringify(error), + stack: 'mockStack', + }); + }); + }); +}); diff --git a/apps/server/src/core/error/loggable/axios-error.loggable.ts b/apps/server/src/core/error/loggable/axios-error.loggable.ts new file mode 100644 index 00000000000..29e6ad32dad --- /dev/null +++ b/apps/server/src/core/error/loggable/axios-error.loggable.ts @@ -0,0 +1,20 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { AxiosError } from 'axios'; + +export class AxiosErrorLoggable extends HttpException implements Loggable { + constructor(private readonly axiosError: AxiosError, protected readonly type: string) { + super(JSON.stringify(axiosError.response?.data), axiosError.status ?? HttpStatus.INTERNAL_SERVER_ERROR, { + cause: axiosError.cause, + }); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: this.axiosError.message, + type: this.type, + data: JSON.stringify(this.axiosError.response?.data), + stack: this.axiosError.stack, + }; + } +} diff --git a/apps/server/src/core/error/loggable/index.ts b/apps/server/src/core/error/loggable/index.ts new file mode 100644 index 00000000000..0470cbee690 --- /dev/null +++ b/apps/server/src/core/error/loggable/index.ts @@ -0,0 +1,2 @@ +export * from './error.loggable'; +export * from './axios-error.loggable'; diff --git a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts index 7e30c5668c7..2a373195bc6 100644 --- a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts +++ b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.spec.ts @@ -1,7 +1,5 @@ -import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { HttpService } from '@nestjs/axios'; -import { Test, TestingModule } from '@nestjs/testing'; import { AcceptConsentRequestBody, AcceptLoginRequestBody, @@ -12,11 +10,15 @@ import { ProviderRedirectResponse, RejectRequestBody, } from '@infra/oauth-provider/dto'; +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; -import { AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; -import { of } from 'rxjs'; +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError, AxiosRequestConfig, Method, RawAxiosRequestHeaders } from 'axios'; +import { of, throwError } from 'rxjs'; +import { ProviderConsentSessionResponse } from '../dto'; +import { HydraOauthFailedLoggableException } from '../loggable'; import { HydraAdapter } from './hydra.adapter'; -import { ProviderConsentSessionResponse } from '../dto/response/consent-session.response'; import resetAllMocks = jest.resetAllMocks; class HydraAdapterSpec extends HydraAdapter { @@ -66,100 +68,66 @@ describe('HydraService', () => { }); describe('request', () => { - it('should return data when called with all parameters', async () => { - const data: { test: string } = { - test: 'data', - }; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - const result: { test: string } = await service.requestSpec( - 'GET', - 'testUrl', - { dataKey: 'dataValue' }, - { headerKey: 'headerValue' } - ); + describe('when called with all parameters', () => { + const setup = () => { + const data: { test: string } = { + test: 'data', + }; - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - headerKey: 'headerValue', - }, - data: { dataKey: 'dataValue' }, - }) - ); - }); + httpService.request.mockReturnValue(of(createAxiosResponse(data))); - it('should return data when called with only necessary parameters', async () => { - const data: { test: string } = { - test: 'data', + return { + data, + }; }; - httpService.request.mockReturnValue(of(createAxiosResponse(data))); - - const result: { test: string } = await service.requestSpec('GET', 'testUrl'); - - expect(result).toEqual(data); - expect(httpService.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'testUrl', - method: 'GET', - headers: { - 'X-Forwarded-Proto': 'https', - }, - }) - ); - }); - }); - - describe('Client Flow', () => { - describe('listOAuth2Clients', () => { - it('should list all oauth2 clients', async () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - }, - { - client_id: 'client2', - }, - ]; - - httpService.request.mockReturnValue(of(createAxiosResponse(data))); + it('should return data', async () => { + const { data } = setup(); - const result: ProviderOauthClient[] = await service.listOAuth2Clients(); + const result: { test: string } = await service.requestSpec( + 'GET', + 'testUrl', + { dataKey: 'dataValue' }, + { headerKey: 'headerValue' } + ); expect(result).toEqual(data); expect(httpService.request).toHaveBeenCalledWith( expect.objectContaining({ - url: `${hydraUri}/clients`, + url: 'testUrl', method: 'GET', headers: { 'X-Forwarded-Proto': 'https', + headerKey: 'headerValue', }, + data: { dataKey: 'dataValue' }, }) ); }); + }); - it('should list all oauth2 clients within parameters', async () => { - const data: ProviderOauthClient[] = [ - { - client_id: 'client1', - owner: 'clientOwner', - }, - ]; + describe('when called with only necessary parameters', () => { + const setup = () => { + const data: { test: string } = { + test: 'data', + }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); - const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + return { + data, + }; + }; + + it('should return data', async () => { + const { data } = setup(); + + const result: { test: string } = await service.requestSpec('GET', 'testUrl'); expect(result).toEqual(data); expect(httpService.request).toHaveBeenCalledWith( expect.objectContaining({ - url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, + url: 'testUrl', method: 'GET', headers: { 'X-Forwarded-Proto': 'https', @@ -169,13 +137,130 @@ describe('HydraService', () => { }); }); + describe('when error occurs', () => { + describe('when error is an axios error', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build({}); + + httpService.request.mockReturnValueOnce(throwError(() => axiosError)); + + return { + axiosError, + }; + }; + + it('should throw hydra oauth loggable exception', async () => { + const { axiosError } = setup(); + + await expect(service.listOAuth2Clients()).rejects.toThrow(new HydraOauthFailedLoggableException(axiosError)); + }); + }); + + describe('when error is any other error', () => { + const setup = () => { + httpService.request.mockReturnValueOnce(throwError(() => new Error('unknown error'))); + }; + + it('should throw the error', async () => { + setup(); + + await expect(service.listOAuth2Clients()).rejects.toThrow(new Error('unknown error')); + }); + }); + }); + }); + + describe('Client Flow', () => { + describe('listOAuth2Clients', () => { + describe('when only clientIds are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = [ + { + client_id: 'client1', + }, + { + client_id: 'client2', + }, + ]; + + httpService.request.mockReturnValue(of(createAxiosResponse(data))); + + return { + data, + }; + }; + + it('should list all oauth2 clients', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(); + + expect(result).toEqual(data); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + + describe('when clientId and other parameters are given', () => { + const setup = () => { + const data: ProviderOauthClient[] = [ + { + client_id: 'client1', + owner: 'clientOwner', + }, + ]; + + httpService.request.mockReturnValue(of(createAxiosResponse(data))); + + return { + data, + }; + }; + + it('should list all oauth2 clients within parameters', async () => { + const { data } = setup(); + + const result: ProviderOauthClient[] = await service.listOAuth2Clients(1, 0, 'client1', 'clientOwner'); + + expect(result).toEqual(data); + expect(httpService.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${hydraUri}/clients?limit=1&offset=0&client_name=client1&owner=clientOwner`, + method: 'GET', + headers: { + 'X-Forwarded-Proto': 'https', + }, + }) + ); + }); + }); + }); + describe('getOAuth2Client', () => { - it('should get oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should get oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.getOAuth2Client('clientId'); expect(result).toEqual(data); @@ -192,12 +277,20 @@ describe('HydraService', () => { }); describe('createOAuth2Client', () => { - it('should create oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should create oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.createOAuth2Client(data); expect(result).toEqual(data); @@ -215,12 +308,20 @@ describe('HydraService', () => { }); describe('updateOAuth2Client', () => { - it('should update oauth2 client', async () => { + const setup = () => { const data: ProviderOauthClient = { client_id: 'client', }; httpService.request.mockReturnValue(of(createAxiosResponse(data))); + return { + data, + }; + }; + + it('should update oauth2 client', async () => { + const { data } = setup(); + const result: ProviderOauthClient = await service.updateOAuth2Client('clientId', data); expect(result).toEqual(data); @@ -238,8 +339,12 @@ describe('HydraService', () => { }); describe('deleteOAuth2Client', () => { - it('should delete oauth2 client', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse({}))); + }; + + it('should delete oauth2 client', async () => { + setup(); await service.deleteOAuth2Client('clientId'); @@ -268,26 +373,30 @@ describe('HydraService', () => { }); describe('getConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const config: AxiosRequestConfig = { method: 'GET', url: `${hydraUri}/oauth2/auth/requests/consent?consent_challenge=${challenge}`, }; httpService.request.mockReturnValue(of(createAxiosResponse({ challenge }))); - // Act + return { + config, + }; + }; + + it('should make http request', async () => { + const { config } = setup(); + const result: ProviderConsentResponse = await service.getConsentRequest(challenge); - // Assert expect(result.challenge).toEqual(challenge); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('acceptConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const body: AcceptConsentRequestBody = { grant_scope: ['offline', 'openid'], }; @@ -301,18 +410,25 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should make http request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.acceptConsentRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('rejectConsentRequest', () => { - it('should make http request', async () => { - // Arrange + const setup = () => { const body: RejectRequestBody = { error: 'error', }; @@ -326,20 +442,36 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should make http request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.rejectConsentRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); }); describe('listConsentSessions', () => { - it('should list all consent sessions', async () => { + const setup = () => { const response: ProviderConsentSessionResponse[] = [{ consent_request: { challenge: 'challenge' } }]; httpService.request.mockReturnValue(of(createAxiosResponse(response))); + return { + response, + }; + }; + + it('should list all consent sessions', async () => { + const { response } = setup(); + const result: ProviderConsentSessionResponse[] = await service.listConsentSessions('userId'); expect(result).toEqual(response); @@ -356,8 +488,12 @@ describe('HydraService', () => { }); describe('revokeConsentSession', () => { - it('should revoke all consent sessions', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse({}))); + }; + + it('should revoke all consent sessions', async () => { + setup(); await service.revokeConsentSession('userId', 'clientId'); @@ -375,7 +511,7 @@ describe('HydraService', () => { describe('Logout Flow', () => { describe('acceptLogoutRequest', () => { - it('should make http request', async () => { + const setup = () => { const responseMock: ProviderRedirectResponse = { redirect_to: 'redirect_mock' }; httpService.request.mockReturnValue(of(createAxiosResponse(responseMock))); const config: AxiosRequestConfig = { @@ -384,6 +520,15 @@ describe('HydraService', () => { headers: { 'X-Forwarded-Proto': 'https' }, }; + return { + responseMock, + config, + }; + }; + + it('should make http request', async () => { + const { responseMock, config } = setup(); + const response: ProviderRedirectResponse = await service.acceptLogoutRequest('challenge_mock'); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); @@ -394,12 +539,20 @@ describe('HydraService', () => { describe('Miscellaneous', () => { describe('introspectOAuth2Token', () => { - it('should return introspect', async () => { + const setup = () => { const response: IntrospectResponse = { active: true, }; httpService.request.mockReturnValue(of(createAxiosResponse(response))); + return { + response, + }; + }; + + it('should return introspect', async () => { + const { response } = setup(); + const result: IntrospectResponse = await service.introspectOAuth2Token('token', 'scope'); expect(result).toEqual(response); @@ -418,8 +571,12 @@ describe('HydraService', () => { }); describe('isInstanceAlive', () => { - it('should check if hydra is alive', async () => { + const setup = () => { httpService.request.mockReturnValue(of(createAxiosResponse(true))); + }; + + it('should check if hydra is alive', async () => { + setup(); const result: boolean = await service.isInstanceAlive(); @@ -459,25 +616,30 @@ describe('HydraService', () => { }); describe('getLoginRequest', () => { - it('should send login request', async () => { - // Arrange + const setup = () => { const requestConfig: AxiosRequestConfig = { method: 'GET', url: `${hydraUri}/oauth2/auth/requests/login?login_challenge=${challenge}`, }; httpService.request.mockReturnValue(of(createAxiosResponse(providerLoginResponse))); - // Act + return { + requestConfig, + }; + }; + + it('should send login request', async () => { + const { requestConfig } = setup(); + const response: ProviderLoginResponse = await service.getLoginRequest(challenge); - // Assert expect(response).toEqual(providerLoginResponse); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(requestConfig)); }); }); describe('acceptLoginRequest', () => { - it('should send accept login request', async () => { + const setup = () => { const body: AcceptLoginRequestBody = { subject: '', force_subject_identifier: '', @@ -494,6 +656,16 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should send accept login request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.acceptLoginRequest(challenge, body); expect(result.redirect_to).toEqual(expectedRedirectTo); @@ -502,8 +674,7 @@ describe('HydraService', () => { }); describe('rejectLoginRequest', () => { - it('should send reject login request', async () => { - // Arrange + const setup = () => { const body: RejectRequestBody = { error: 'error', }; @@ -517,10 +688,18 @@ describe('HydraService', () => { of(createAxiosResponse({ redirect_to: expectedRedirectTo })) ); - // Act + return { + body, + config, + expectedRedirectTo, + }; + }; + + it('should send reject login request', async () => { + const { body, config, expectedRedirectTo } = setup(); + const result: ProviderRedirectResponse = await service.rejectLoginRequest(challenge, body); - // Assert expect(result.redirect_to).toEqual(expectedRedirectTo); expect(httpService.request).toHaveBeenCalledWith(expect.objectContaining(config)); }); diff --git a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts index f554a15abd3..3e2d389d643 100644 --- a/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts +++ b/apps/server/src/infra/oauth-provider/hydra/hydra.adapter.ts @@ -1,21 +1,23 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; -import { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios'; +import { AxiosResponse, isAxiosError, Method, RawAxiosRequestHeaders } from 'axios'; import QueryString from 'qs'; -import { Observable, firstValueFrom } from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { URL } from 'url'; import { AcceptConsentRequestBody, AcceptLoginRequestBody, IntrospectResponse, ProviderConsentResponse, + ProviderConsentSessionResponse, ProviderLoginResponse, ProviderOauthClient, ProviderRedirectResponse, RejectRequestBody, } from '../dto'; -import { ProviderConsentSessionResponse } from '../dto/response/consent-session.response'; +import { HydraOauthFailedLoggableException } from '../loggable'; import { OauthProviderService } from '../oauth-provider.service'; @Injectable() @@ -160,15 +162,26 @@ export class HydraAdapter extends OauthProviderService { data?: unknown, additionalHeaders: RawAxiosRequestHeaders = {} ): Promise { - const observable: Observable> = this.httpService.request({ - url, - method, - headers: { - 'X-Forwarded-Proto': 'https', - ...additionalHeaders, - }, - data, - }); + const observable: Observable> = this.httpService + .request({ + url, + method, + headers: { + 'X-Forwarded-Proto': 'https', + ...additionalHeaders, + }, + data, + }) + .pipe( + catchError((error: unknown) => { + if (isAxiosError(error)) { + throw new HydraOauthFailedLoggableException(error); + } else { + throw error; + } + }) + ); + const response: AxiosResponse = await firstValueFrom(observable); return response.data; } diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts new file mode 100644 index 00000000000..a78b365d126 --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { HydraOauthFailedLoggableException } from './hydra-oauth-failed-loggable-exception'; + +describe(HydraOauthFailedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build({ stack: 'someStack' }); + + const exception = new HydraOauthFailedLoggableException(axiosError); + + return { + exception, + axiosError, + error, + }; + }; + + it('should return the correct log message', () => { + const { exception, axiosError, error } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'HYDRA_OAUTH_FAILED', + message: axiosError.message, + stack: axiosError.stack, + data: JSON.stringify(error), + }); + }); + }); +}); diff --git a/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts new file mode 100644 index 00000000000..c92dd3c7fff --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/hydra-oauth-failed-loggable-exception.ts @@ -0,0 +1,8 @@ +import { AxiosErrorLoggable } from '@src/core/error/loggable'; +import { AxiosError } from 'axios'; + +export class HydraOauthFailedLoggableException extends AxiosErrorLoggable { + constructor(error: AxiosError) { + super(error, 'HYDRA_OAUTH_FAILED'); + } +} diff --git a/apps/server/src/infra/oauth-provider/loggable/index.ts b/apps/server/src/infra/oauth-provider/loggable/index.ts new file mode 100644 index 00000000000..677fe4f84e6 --- /dev/null +++ b/apps/server/src/infra/oauth-provider/loggable/index.ts @@ -0,0 +1 @@ +export * from './hydra-oauth-failed-loggable-exception'; diff --git a/apps/server/src/modules/oauth/loggable/index.ts b/apps/server/src/modules/oauth/loggable/index.ts index b4e63107161..4c35983a4ca 100644 --- a/apps/server/src/modules/oauth/loggable/index.ts +++ b/apps/server/src/modules/oauth/loggable/index.ts @@ -1,3 +1,4 @@ export * from './oauth-sso.error'; export * from './sso-error-code.enum'; export * from './user-not-found-after-provisioning.loggable-exception'; +export * from './token-request-loggable-exception'; diff --git a/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts index 35659b2778f..cc1486adcb7 100644 --- a/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts +++ b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts @@ -1,6 +1,10 @@ import { InternalServerErrorException } from '@nestjs/common'; import { SSOErrorCode } from './sso-error-code.enum'; +/** + * @deprecated Please create a loggable instead. + * This will be removed with: https://ticketsystem.dbildungscloud.de/browse/N21-1483 + */ export class OAuthSSOError extends InternalServerErrorException { readonly message: string; diff --git a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts new file mode 100644 index 00000000000..6716175bdbe --- /dev/null +++ b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.spec.ts @@ -0,0 +1,34 @@ +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; +import { TokenRequestLoggableException } from './token-request-loggable-exception'; + +describe(TokenRequestLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + const exception = new TokenRequestLoggableException(axiosError); + + return { + axiosError, + exception, + error, + }; + }; + + it('should return the correct log message', () => { + const { axiosError, exception, error } = setup(); + + const logMessage = exception.getLogMessage(); + + expect(logMessage).toStrictEqual({ + type: 'OAUTH_TOKEN_REQUEST_ERROR', + message: axiosError.message, + data: JSON.stringify(error), + stack: axiosError.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts new file mode 100644 index 00000000000..fd852186829 --- /dev/null +++ b/apps/server/src/modules/oauth/loggable/token-request-loggable-exception.ts @@ -0,0 +1,8 @@ +import { AxiosErrorLoggable } from '@src/core/error/loggable'; +import { AxiosError } from 'axios'; + +export class TokenRequestLoggableException extends AxiosErrorLoggable { + constructor(error: AxiosError) { + super(error, 'OAUTH_TOKEN_REQUEST_ERROR'); + } +} diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts index 12c0a381d8b..af03a6fdda2 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts @@ -2,9 +2,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; +import { axiosErrorFactory } from '@shared/testing/factory'; +import { AxiosError } from 'axios'; import { of, throwError } from 'rxjs'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; -import { OAuthSSOError } from '../loggable'; +import { TokenRequestLoggableException } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -93,12 +95,65 @@ describe('OauthAdapterServive', () => { }); describe('when no token got returned', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { + error, + }; + }; + it('should throw an error', async () => { - httpService.post.mockReturnValueOnce(throwError(() => 'error')); + const { error } = setup(); const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); - await expect(resp).rejects.toEqual(new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step')); + await expect(resp).rejects.toEqual(error); + }); + }); + + describe('when error got returned', () => { + describe('when error is a unknown error', () => { + const setup = () => { + const error = new Error('unknown error'); + httpService.post.mockReturnValueOnce(throwError(() => error)); + + return { + error, + }; + }; + + it('should throw the default sso error', async () => { + const { error } = setup(); + + const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + + await expect(resp).rejects.toEqual(error); + }); + }); + + describe('when error is a axios error', () => { + const setup = () => { + const error = { + error: 'invalid_request', + }; + const axiosError: AxiosError = axiosErrorFactory.withError(error).build(); + + httpService.post.mockReturnValueOnce(throwError(() => axiosError)); + + return { + axiosError, + }; + }; + + it('should throw an error', async () => { + const { axiosError } = setup(); + + const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + + await expect(resp).rejects.toEqual(new TokenRequestLoggableException(axiosError)); + }); }); }); }); diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts index 6b008b610cf..4ab048b84c4 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts @@ -1,10 +1,10 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common/decorators'; -import { AxiosResponse } from 'axios'; +import { AxiosResponse, isAxiosError } from 'axios'; import JwksRsa from 'jwks-rsa'; import QueryString from 'qs'; import { lastValueFrom, Observable } from 'rxjs'; -import { OAuthSSOError } from '../loggable'; +import { TokenRequestLoggableException } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @Injectable() @@ -40,8 +40,11 @@ export class OauthAdapterService { let responseToken: AxiosResponse; try { responseToken = await lastValueFrom(observable); - } catch (error) { - throw new OAuthSSOError('Requesting token failed.', 'sso_auth_code_step'); + } catch (error: unknown) { + if (isAxiosError(error)) { + throw new TokenRequestLoggableException(error); + } + throw error; } return responseToken.data; diff --git a/apps/server/src/shared/testing/factory/axios-error.factory.ts b/apps/server/src/shared/testing/factory/axios-error.factory.ts new file mode 100644 index 00000000000..089179dafef --- /dev/null +++ b/apps/server/src/shared/testing/factory/axios-error.factory.ts @@ -0,0 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { axiosResponseFactory } from '@shared/testing'; +import { AxiosError, AxiosHeaders } from 'axios'; +import { Factory } from 'fishery'; + +class AxiosErrorFactory extends Factory { + withError(error: unknown): this { + return this.params({ + response: axiosResponseFactory.build({ status: HttpStatus.BAD_REQUEST, data: error }), + }); + } +} + +export const axiosErrorFactory = AxiosErrorFactory.define(() => { + return { + status: HttpStatus.BAD_REQUEST, + config: { headers: new AxiosHeaders() }, + isAxiosError: true, + code: HttpStatus.BAD_REQUEST.toString(), + message: 'Bad Request', + name: 'BadRequest', + response: axiosResponseFactory.build({ status: HttpStatus.BAD_REQUEST }), + stack: 'mockStack', + toJSON: () => { + return { someJson: 'someJson' }; + }, + }; +}); diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index bd8d0913a72..54fac672098 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -39,3 +39,4 @@ export * from './user.do.factory'; export * from './user.factory'; export * from './legacy-file-entity-mock.factory'; export * from './jwt.test.factory'; +export * from './axios-error.factory'; From b11b022db6c28abfa6900b45943b4760d856d826 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Mon, 13 Nov 2023 11:13:18 +0100 Subject: [PATCH 12/13] N21-1374 removes old login flow (#4541) --- .../controller/api-test/oauth-sso.api.spec.ts | 582 ----------- .../controller/dto/authorization.params.ts | 3 - .../controller/oauth-sso.controller.spec.ts | 5 - .../oauth/controller/oauth-sso.controller.ts | 174 +--- .../oauth/mapper/oauth-login-state.mapper.ts | 9 - .../src/modules/oauth/oauth-api.module.ts | 10 +- .../oauth/service/oauth.service.spec.ts | 92 +- .../modules/oauth/service/oauth.service.ts | 25 - .../oauth/uc/dto/oauth-login-state.dto.ts | 21 - .../modules/oauth/uc/hydra-oauth.uc.spec.ts | 2 +- .../src/modules/oauth/uc/hydra-oauth.uc.ts | 7 +- apps/server/src/modules/oauth/uc/index.ts | 1 - .../src/modules/oauth/uc/oauth.uc.spec.ts | 923 ------------------ apps/server/src/modules/oauth/uc/oauth.uc.ts | 149 --- config/default.schema.json | 5 - src/services/config/publicAppConfigService.js | 1 - 16 files changed, 24 insertions(+), 1985 deletions(-) delete mode 100644 apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts delete mode 100644 apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts delete mode 100644 apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts delete mode 100644 apps/server/src/modules/oauth/uc/oauth.uc.spec.ts delete mode 100644 apps/server/src/modules/oauth/uc/oauth.uc.ts diff --git a/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts b/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts deleted file mode 100644 index a259c405cfb..00000000000 --- a/apps/server/src/modules/oauth/controller/api-test/oauth-sso.api.spec.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Account, EntityId, SchoolEntity, SystemEntity, User } from '@shared/domain'; -import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; -import { - accountFactory, - cleanupCollections, - mapUserToCurrentUser, - schoolFactory, - systemFactory, - userFactory, -} from '@shared/testing'; -import { JwtTestFactory } from '@shared/testing/factory/jwt.test.factory'; -import { userLoginMigrationFactory } from '@shared/testing/factory/user-login-migration.factory'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; -import { SanisResponse, SanisRole } from '@modules/provisioning/strategy/sanis/response'; -import { ServerTestModule } from '@modules/server'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { UUID } from 'bson'; -import { Request } from 'express'; -import request, { Response } from 'supertest'; -import { SSOAuthenticationError } from '../../interface/sso-authentication-error.enum'; -import { OauthTokenResponse } from '../../service/dto'; -import { AuthorizationParams, SSOLoginQuery } from '../dto'; - -jest.mock('jwks-rsa', () => () => { - return { - getKeys: jest.fn(), - getSigningKey: jest.fn().mockResolvedValue({ - kid: 'kid', - alg: 'RS256', - getPublicKey: jest.fn().mockReturnValue(JwtTestFactory.getPublicKey()), - rsaPublicKey: JwtTestFactory.getPublicKey(), - }), - getSigningKeys: jest.fn(), - }; -}); - -describe('OAuth SSO Controller (API)', () => { - let app: INestApplication; - let em: EntityManager; - let currentUser: ICurrentUser; - let axiosMock: MockAdapter; - - const sessionCookieName: string = Configuration.get('SESSION__NAME') as string; - beforeAll(async () => { - Configuration.set('PUBLIC_BACKEND_URL', 'http://localhost:3030/api'); - const schulcloudJwt: string = JwtTestFactory.createJwt(); - - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - req.headers.authorization = schulcloudJwt; - return true; - }, - }) - .compile(); - - axiosMock = new MockAdapter(axios); - app = moduleRef.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - const kcAdminService = app.get(KeycloakAdministrationService); - - axiosMock.onGet(kcAdminService.getWellKnownUrl()).reply(200, { - issuer: 'issuer', - token_endpoint: 'token_endpoint', - authorization_endpoint: 'authorization_endpoint', - end_session_endpoint: 'end_session_endpoint', - jwks_uri: 'jwks_uri', - }); - }); - - afterAll(async () => { - await app.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - const setupSessionState = async (systemId: EntityId, migration: boolean) => { - const query: SSOLoginQuery = { - migration, - }; - - const response: Response = await request(app.getHttpServer()) - .get(`/sso/login/${systemId}`) - .query(query) - .expect(302) - .expect('set-cookie', new RegExp(`^${sessionCookieName}`)); - - const cookies: string[] = response.get('Set-Cookie'); - const redirect: string = response.get('Location'); - const matchState: RegExpMatchArray | null = redirect.match(/(?<=state=)([^&]+)/); - const state = matchState ? matchState[0] : ''; - - return { - cookies, - state, - }; - }; - - const setup = async () => { - const externalUserId = 'externalUserId'; - const system: SystemEntity = systemFactory.withOauthConfig().buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); - const user: User = userFactory.buildWithId({ externalId: externalUserId, school }); - const account: Account = accountFactory.buildWithId({ systemId: system.id, userId: user.id }); - - await em.persistAndFlush([system, user, school, account]); - em.clear(); - - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'state'; - - return { - system, - user, - externalUserId, - school, - query, - }; - }; - - describe('[GET] sso/login/:systemId', () => { - describe('when no error occurs', () => { - it('should redirect to the authentication url and set a session cookie', async () => { - const { system } = await setup(); - - await request(app.getHttpServer()) - .get(`/sso/login/${system.id}`) - .expect(302) - .expect('set-cookie', new RegExp(`^${sessionCookieName}`)) - .expect( - 'Location', - /^http:\/\/mock.de\/auth\?client_id=12345&redirect_uri=http%3A%2F%2Flocalhost%3A3030%2Fapi%2Fv3%2Fsso%2Foauth&response_type=code&scope=openid\+uuid&state=\w*/ - ); - }); - }); - - describe('when an error occurs', () => { - it('should redirect to the login page', async () => { - const unknownSystemId: string = new ObjectId().toHexString(); - const clientUrl: string = Configuration.get('HOST') as string; - - await request(app.getHttpServer()) - .get(`/sso/login/${unknownSystemId}`) - .expect(302) - .expect('Location', `${clientUrl}/login?error=sso_login_failed`); - }); - }); - }); - - describe('[GET] sso/oauth', () => { - describe('when the session has no oauthLoginState', () => { - it('should return 401 Unauthorized', async () => { - await setup(); - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'state'; - - await request(app.getHttpServer()).get(`/sso/oauth`).query(query).expect(401); - }); - }); - - describe('when the session and the request have a different state', () => { - it('should return 401 Unauthorized', async () => { - const { system } = await setup(); - const { cookies } = await setupSessionState(system.id, false); - const query: AuthorizationParams = new AuthorizationParams(); - query.code = 'code'; - query.state = 'wrongState'; - - await request(app.getHttpServer()).get(`/sso/oauth`).set('Cookie', cookies).query(query).expect(401); - }); - }); - - describe('when code and state are valid', () => { - it('should set a jwt and redirect', async () => { - const { system, externalUserId, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const baseUrl: string = Configuration.get('HOST') as string; - query.code = 'code'; - query.state = state; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'testUser', - iss: system.oauthConfig?.issuer, - aud: system.oauthConfig?.clientId, - // For OIDC provisioning strategy - external_sub: externalUserId, - }); - - axiosMock.onPost(system.oauthConfig?.tokenEndpoint).reply(200, { - id_token: idToken, - refresh_token: 'refreshToken', - access_token: 'accessToken', - }); - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/dashboard`) - .expect( - (res: Response) => res.get('Set-Cookie').filter((value: string) => value.startsWith('jwt')).length === 1 - ); - }); - }); - - describe('when an error occurs during the login process', () => { - it('should redirect to the login page', async () => { - const { system, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const clientUrl: string = Configuration.get('HOST') as string; - query.error = SSOAuthenticationError.ACCESS_DENIED; - query.state = state; - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${clientUrl}/login?error=access_denied&provider=${system.oauthConfig?.provider as string}` - ); - }); - }); - - describe('when a faulty query is passed', () => { - it('should redirect to the login page with an error', async () => { - const { system, query } = await setup(); - const { state, cookies } = await setupSessionState(system.id, false); - const clientUrl: string = Configuration.get('HOST') as string; - query.state = state; - query.code = undefined; - - await request(app.getHttpServer()) - .get(`/sso/oauth`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${clientUrl}/login?error=sso_auth_code_step&provider=${system.oauthConfig?.provider as string}` - ); - }); - }); - }); - - describe('[GET] sso/oauth/migration', () => { - const mockPostOauthTokenEndpoint = ( - idToken: string, - targetSystem: SystemEntity, - targetUserId: string, - schoolExternalId: string, - officialSchoolNumber: string - ) => { - axiosMock - .onPost(targetSystem.oauthConfig?.tokenEndpoint) - .replyOnce(200, { - id_token: idToken, - refresh_token: 'refreshToken', - access_token: 'accessToken', - }) - .onGet(targetSystem.provisioningUrl) - .replyOnce(200, { - pid: targetUserId, - person: { - name: { - familienname: 'familienName', - vorname: 'vorname', - }, - geschlecht: 'weiblich', - lokalisierung: 'not necessary', - vertrauensstufe: 'not necessary', - }, - personenkontexte: [ - { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), - rolle: SanisRole.LEHR, - organisation: { - id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), - kennung: officialSchoolNumber, - name: 'schulName', - typ: 'not necessary', - }, - personenstatus: 'not necessary', - }, - ], - }); - }; - - describe('when the session has no oauthLoginState', () => { - it('should return 401 Unauthorized', async () => { - const { query } = await setup(); - - await request(app.getHttpServer()).get(`/sso/oauth/migration`).query(query).expect(401); - }); - }); - - describe('when the migration is successful', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const targetSchoolExternalId = 'aef1f4fd-c323-466e-962b-a84354c0e714'; - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - const sourceUserAccount: Account = accountFactory.buildWithId({ - userId: sourceUser.id, - systemId: sourceSystem.id, - username: sourceUser.email, - }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceUser, sourceUserAccount, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - targetSchoolExternalId, - sourceSystem, - sourceUser, - externalUserId, - query, - cookies, - }; - }; - - it('should redirect to the success page', async () => { - const { query, sourceUser, targetSystem, externalUserId, cookies, sourceSystem, targetSchoolExternalId } = - await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'testUser', - iss: targetSystem.oauthConfig?.issuer, - aud: targetSystem.oauthConfig?.clientId, - external_sub: externalUserId, - }); - - mockPostOauthTokenEndpoint(idToken, targetSystem, currentUser.userId, targetSchoolExternalId, 'NI_11111'); - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect( - 'Location', - `${baseUrl}/migration/success?sourceSystem=${ - currentUser.systemId ? currentUser.systemId : '' - }&targetSystem=${targetSystem.id}` - ); - }); - }); - - describe('when currentUser has no systemId', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11110', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - await em.persistAndFlush([targetSystem, sourceUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - sourceUser, - query, - cookies, - }; - }; - - it('should throw UnprocessableEntityException', async () => { - const { sourceUser, query, cookies } = await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, undefined); - query.error = SSOAuthenticationError.INVALID_REQUEST; - - await request(app.getHttpServer()).get(`/sso/oauth/migration`).set('Cookie', cookies).query(query).expect(422); - }); - }); - - describe('when invalid request', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceSchool, sourceUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - sourceSystem, - sourceUser, - query, - cookies, - }; - }; - - it('should redirect to the general migration error page', async () => { - const { sourceUser, sourceSystem, query, cookies } = await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - query.error = SSOAuthenticationError.INVALID_REQUEST; - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/migration/error`); - }); - }); - - describe('when schoolnumbers mismatch', () => { - const setupMigration = async () => { - const { externalUserId, query } = await setup(); - - const targetSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }, new ObjectId().toHexString(), {}); - const sourceSystem: SystemEntity = systemFactory - .withOauthConfig() - .buildWithId({ provisioningStrategy: SystemProvisioningStrategy.ISERV }, new ObjectId().toHexString(), {}); - - const sourceSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [sourceSystem], - officialSchoolNumber: '11111', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ - school: sourceSchool, - targetSystem, - sourceSystem, - startedAt: new Date('2022-12-17T03:24:00'), - }); - - const targetSchool: SchoolEntity = schoolFactory.buildWithId({ - systems: [targetSystem], - officialSchoolNumber: '22222', - externalId: 'aef1f4fd-c323-466e-962b-a84354c0e713', - }); - - const sourceUser: User = userFactory.buildWithId({ externalId: externalUserId, school: sourceSchool }); - - const targetUser: User = userFactory.buildWithId({ - externalId: 'differentExternalUserId', - school: targetSchool, - }); - - await em.persistAndFlush([sourceSystem, targetSystem, sourceUser, targetUser, userLoginMigration]); - - const { state, cookies } = await setupSessionState(targetSystem.id, true); - query.code = 'code'; - query.state = state; - - return { - targetSystem, - sourceSystem, - sourceUser, - targetUser, - targetSchoolExternalId: targetSchool.externalId as string, - query, - cookies, - }; - }; - - it('should redirect to the login page with an schoolnumber mismatch error', async () => { - const { targetSystem, sourceUser, targetUser, sourceSystem, targetSchoolExternalId, query, cookies } = - await setupMigration(); - currentUser = mapUserToCurrentUser(sourceUser, undefined, sourceSystem.id); - const baseUrl: string = Configuration.get('HOST') as string; - - const idToken: string = JwtTestFactory.createJwt({ - sub: 'differentExternalUserId', - iss: targetSystem.oauthConfig?.issuer, - aud: targetSystem.oauthConfig?.clientId, - external_sub: 'differentExternalUserId', - }); - - mockPostOauthTokenEndpoint(idToken, targetSystem, targetUser.id, targetSchoolExternalId, 'NI_22222'); - - await request(app.getHttpServer()) - .get(`/sso/oauth/migration`) - .set('Cookie', cookies) - .query(query) - .expect(302) - .expect('Location', `${baseUrl}/migration/error?sourceSchoolNumber=11111&targetSchoolNumber=22222`); - }); - }); - - afterAll(() => { - axiosMock.restore(); - }); - }); -}); diff --git a/apps/server/src/modules/oauth/controller/dto/authorization.params.ts b/apps/server/src/modules/oauth/controller/dto/authorization.params.ts index 1a20985ce43..af76d0799e4 100644 --- a/apps/server/src/modules/oauth/controller/dto/authorization.params.ts +++ b/apps/server/src/modules/oauth/controller/dto/authorization.params.ts @@ -1,9 +1,6 @@ import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { SSOAuthenticationError } from '../../interface/sso-authentication-error.enum'; -/** - * @deprecated - */ export class AuthorizationParams { @IsOptional() @IsString() 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 3d1a470e227..c42eeb22e24 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 @@ -8,7 +8,6 @@ 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 { OauthUc } from '../uc'; describe('OAuthController', () => { let module: TestingModule; @@ -52,10 +51,6 @@ describe('OAuthController', () => { provide: LegacyLogger, useValue: createMock(), }, - { - provide: OauthUc, - useValue: createMock(), - }, { provide: HydraOauthUc, useValue: createMock(), diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts index 5ff7e7cae02..61ed319d1cd 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -1,150 +1,18 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { - Controller, - Get, - InternalServerErrorException, - Param, - Query, - Req, - Res, - Session, - UnauthorizedException, - UnprocessableEntityException, -} from '@nestjs/common'; -import { ApiOkResponse, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ISession } from '@shared/domain/types/session'; +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, Get, Param, Query, Req, UnauthorizedException } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { LegacyLogger } from '@src/core/logger'; -import { ICurrentUser, Authenticate, CurrentUser, JWT } from '@modules/authentication'; -import { OAuthMigrationError } from '@modules/user-login-migration/error/oauth-migration.error'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { CookieOptions, Request, Response } from 'express'; -import { HydraOauthUc } from '../uc/hydra-oauth.uc'; -import { UserMigrationResponse } from './dto/user-migration.response'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; +import { Request } from 'express'; import { OAuthTokenDto } from '../interface'; -import { OauthLoginStateMapper } from '../mapper/oauth-login-state.mapper'; -import { UserMigrationMapper } from '../mapper/user-migration.mapper'; -import { OAuthProcessDto } from '../service/dto'; -import { OauthUc } from '../uc'; -import { OauthLoginStateDto } from '../uc/dto/oauth-login-state.dto'; -import { AuthorizationParams, SSOLoginQuery, SystemIdParams } from './dto'; +import { HydraOauthUc } from '../uc'; +import { AuthorizationParams } from './dto'; import { StatelessAuthorizationParams } from './dto/stateless-authorization.params'; @ApiTags('SSO') @Controller('sso') export class OauthSSOController { - private readonly clientUrl: string; - - constructor( - private readonly oauthUc: OauthUc, - private readonly hydraUc: HydraOauthUc, - private readonly logger: LegacyLogger - ) { + constructor(private readonly hydraUc: HydraOauthUc, private readonly logger: LegacyLogger) { this.logger.setContext(OauthSSOController.name); - this.clientUrl = Configuration.get('HOST') as string; - } - - private errorHandler(error: unknown, session: ISession, res: Response, provider?: string) { - this.logger.error(error); - const ssoError: OAuthSSOError = error instanceof OAuthSSOError ? error : new OAuthSSOError(); - - session.destroy((err) => { - this.logger.log(err); - }); - - const errorRedirect: URL = new URL('/login', this.clientUrl); - errorRedirect.searchParams.append('error', ssoError.errorcode); - - if (provider) { - errorRedirect.searchParams.append('provider', provider); - } - - res.redirect(errorRedirect.toString()); - } - - private migrationErrorHandler(error: unknown, session: ISession, res: Response) { - const migrationError: OAuthMigrationError = - error instanceof OAuthMigrationError ? error : new OAuthMigrationError(); - - session.destroy((err) => { - this.logger.log(err); - }); - - const errorRedirect: URL = new URL('/migration/error', this.clientUrl); - - if (migrationError.officialSchoolNumberFromSource && migrationError.officialSchoolNumberFromTarget) { - errorRedirect.searchParams.append('sourceSchoolNumber', migrationError.officialSchoolNumberFromSource); - errorRedirect.searchParams.append('targetSchoolNumber', migrationError.officialSchoolNumberFromTarget); - } - - res.redirect(errorRedirect.toString()); - } - - private sessionHandler(session: ISession, query: AuthorizationParams): OauthLoginStateDto { - if (!session.oauthLoginState) { - throw new UnauthorizedException('Oauth session not found'); - } - - const oauthLoginState: OauthLoginStateDto = OauthLoginStateMapper.mapSessionToDto(session); - - if (oauthLoginState.state !== query.state) { - throw new UnauthorizedException(`Invalid state. Got: ${query.state} Expected: ${oauthLoginState.state}`); - } - - return oauthLoginState; - } - - @Get('login/:systemId') - async getAuthenticationUrl( - @Session() session: ISession, - @Res() res: Response, - @Param() params: SystemIdParams, - @Query() query: SSOLoginQuery - ): Promise { - try { - const redirect: string = await this.oauthUc.startOauthLogin( - session, - params.systemId, - query.migration || false, - query.postLoginRedirect - ); - - res.redirect(redirect); - } catch (error) { - this.errorHandler(error, session, res); - } - } - - @Get('oauth') - async startOauthAuthorizationCodeFlow( - @Session() session: ISession, - @Res() res: Response, - @Query() query: AuthorizationParams - ): Promise { - const oauthLoginState: OauthLoginStateDto = this.sessionHandler(session, query); - - try { - const oauthProcessDto: OAuthProcessDto = await this.oauthUc.processOAuthLogin( - oauthLoginState, - query.code, - query.error - ); - - if (oauthProcessDto.jwt) { - const cookieDefaultOptions: CookieOptions = { - httpOnly: Configuration.get('COOKIE__HTTP_ONLY') as boolean, - sameSite: Configuration.get('COOKIE__SAME_SITE') as 'lax' | 'strict' | 'none', - secure: Configuration.get('COOKIE__SECURE') as boolean, - expires: new Date(Date.now() + (Configuration.get('COOKIE__EXPIRES_SECONDS') as number)), - }; - - res.cookie('jwt', oauthProcessDto.jwt, cookieDefaultOptions); - } - - res.redirect(oauthProcessDto.redirect); - } catch (error) { - this.errorHandler(error, session, res, oauthLoginState.provider); - } } @Get('hydra/:oauthClientId') @@ -166,7 +34,7 @@ export class OauthSSOController { ): Promise { let jwt: string; const authHeader: string | undefined = req.headers.authorization; - if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) { + if (authHeader?.toLowerCase()?.startsWith('bearer ')) { [, jwt] = authHeader.split(' '); } else { throw new UnauthorizedException( @@ -175,30 +43,4 @@ export class OauthSSOController { } return this.hydraUc.requestAuthCode(currentUser.userId, jwt, oauthClientId); } - - @Get('oauth/migration') - @Authenticate('jwt') - @ApiOkResponse({ description: 'The User has been succesfully migrated.' }) - @ApiResponse({ type: InternalServerErrorException, description: 'The migration of the User was not possible. ' }) - async migrateUser( - @JWT() jwt: string, - @Session() session: ISession, - @CurrentUser() currentUser: ICurrentUser, - @Query() query: AuthorizationParams, - @Res() res: Response - ): Promise { - const oauthLoginState: OauthLoginStateDto = this.sessionHandler(session, query); - - if (!currentUser.systemId) { - throw new UnprocessableEntityException('Current user does not have a system.'); - } - - try { - const migration: MigrationDto = await this.oauthUc.migrate(jwt, currentUser.userId, query, oauthLoginState); - const response: UserMigrationResponse = UserMigrationMapper.mapDtoToResponse(migration); - res.redirect(response.redirect); - } catch (error) { - this.migrationErrorHandler(error, session, res); - } - } } diff --git a/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts b/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts deleted file mode 100644 index 67c1ae8a6ef..00000000000 --- a/apps/server/src/modules/oauth/mapper/oauth-login-state.mapper.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ISession } from '@shared/domain/types/session'; -import { OauthLoginStateDto } from '../uc/dto/oauth-login-state.dto'; - -export class OauthLoginStateMapper { - static mapSessionToDto(session: ISession): OauthLoginStateDto { - const dto = new OauthLoginStateDto(session.oauthLoginState as OauthLoginStateDto); - return dto; - } -} diff --git a/apps/server/src/modules/oauth/oauth-api.module.ts b/apps/server/src/modules/oauth/oauth-api.module.ts index 98e62d87eca..2efacf66adf 100644 --- a/apps/server/src/modules/oauth/oauth-api.module.ts +++ b/apps/server/src/modules/oauth/oauth-api.module.ts @@ -1,15 +1,15 @@ -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; 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 { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { OauthSSOController } from './controller/oauth-sso.controller'; import { OauthModule } from './oauth.module'; -import { HydraOauthUc, OauthUc } from './uc'; +import { HydraOauthUc } from './uc'; @Module({ imports: [ @@ -24,6 +24,6 @@ import { HydraOauthUc, OauthUc } from './uc'; LoggerModule, ], controllers: [OauthSSOController], - providers: [OauthUc, HydraOauthUc], + providers: [HydraOauthUc], }) export class OauthApiModule {} 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 9c4b45582df..1ea2fe5ce07 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -1,23 +1,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity } from '@shared/domain'; -import { UserDO } from '@shared/domain/domainobject/user.do'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { DefaultEncryptionService, IEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; -import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; +import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningDto, ProvisioningService } from '@modules/provisioning'; import { ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; -import { LegacySchoolService } from '@modules/legacy-school'; 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 { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, OauthConfig, SchoolFeatures, SystemEntity } from '@shared/domain'; +import { UserDO } from '@shared/domain/domainobject/user.do'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { legacySchoolDoFactory, setupEntities, systemFactory, userDoFactory } from '@shared/testing'; +import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; import { OAuthService } from './oauth.service'; @@ -560,80 +560,4 @@ describe('OAuthService', () => { }); }); }); - - describe('getAuthenticationUrl is called', () => { - describe('when a normal authentication url is requested', () => { - it('should return a authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', false); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth&response_type=code&scope=openid+uuid&state=state' - ); - }); - }); - - describe('when a migration authentication url is requested', () => { - it('should return a authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost.de/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', true); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth%2Fmigration&response_type=code&scope=openid+uuid&state=state' - ); - }); - - it('should return add an idp hint if existing authentication url', () => { - const oauthConfig: OauthConfig = new OauthConfig({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost.de/api/v3/sso/oauth/testsystemId', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - idpHint: 'TheIdpHint', - }); - - const result: string = service.getAuthenticationUrl(oauthConfig, 'state', true); - - expect(result).toEqual( - 'http://mock.de/auth?client_id=12345&redirect_uri=https%3A%2F%2Fmock.de%2Fapi%2Fv3%2Fsso%2Foauth%2Fmigration&response_type=code&scope=openid+uuid&state=state&kc_idp_hint=TheIdpHint' - ); - }); - }); - }); }); diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 190c962cd97..1f7935d919e 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -186,31 +186,6 @@ export class OAuthService { return redirect; } - getAuthenticationUrl(oauthConfig: OauthConfig, state: string, migration: boolean): string { - const redirectUri: string = this.getRedirectUri(migration); - - const authenticationUrl: URL = new URL(oauthConfig.authEndpoint); - authenticationUrl.searchParams.append('client_id', oauthConfig.clientId); - authenticationUrl.searchParams.append('redirect_uri', redirectUri); - authenticationUrl.searchParams.append('response_type', oauthConfig.responseType); - authenticationUrl.searchParams.append('scope', oauthConfig.scope); - authenticationUrl.searchParams.append('state', state); - if (oauthConfig.idpHint) { - authenticationUrl.searchParams.append('kc_idp_hint', oauthConfig.idpHint); - } - - return authenticationUrl.toString(); - } - - getRedirectUri(migration: boolean) { - const publicBackendUrl: string = Configuration.get('PUBLIC_BACKEND_URL') as string; - - const path: string = migration ? 'api/v3/sso/oauth/migration' : 'api/v3/sso/oauth'; - const redirectUri: URL = new URL(path, publicBackendUrl); - - return redirectUri.toString(); - } - private buildTokenRequestPayload( code: string, oauthConfig: OauthConfig, diff --git a/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts b/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts deleted file mode 100644 index 10d01b596d2..00000000000 --- a/apps/server/src/modules/oauth/uc/dto/oauth-login-state.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { EntityId } from '@shared/domain'; - -export class OauthLoginStateDto { - state: string; - - systemId: EntityId; - - provider: string; - - postLoginRedirect?: string; - - userLoginMigration: boolean; - - constructor(props: OauthLoginStateDto) { - this.state = props.state; - this.systemId = props.systemId; - this.postLoginRedirect = props.postLoginRedirect; - this.provider = props.provider; - this.userLoginMigration = props.userLoginMigration; - } -} 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 3d42b0e977f..339bc6c09d9 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 @@ -13,7 +13,7 @@ import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; +import { OAuthSSOError } from '../loggable'; import { OAuthTokenDto } from '../interface'; class HydraOauthUcSpec extends HydraOauthUc { diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index 905cd3c8802..2c461e6db4d 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -1,12 +1,11 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { OauthConfig } from '@shared/domain'; import { LegacyLogger } from '@src/core/logger'; -import { HydraRedirectDto } from '@modules/oauth/service/dto/hydra.redirect.dto'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; +import { OAuthSSOError } from '../loggable'; import { HydraSsoService } from '../service/hydra.service'; import { OAuthService } from '../service/oauth.service'; @@ -22,8 +21,6 @@ export class HydraOauthUc { private readonly MAX_REDIRECTS: number = 10; - private readonly HYDRA_PUBLIC_URI: string = Configuration.get('HYDRA_PUBLIC_URI') as string; - async getOauthToken(oauthClientId: string, code?: string, error?: string): Promise { if (error || !code) { throw new OAuthSSOError( diff --git a/apps/server/src/modules/oauth/uc/index.ts b/apps/server/src/modules/oauth/uc/index.ts index 32e4dce0f74..e1a569e5f88 100644 --- a/apps/server/src/modules/oauth/uc/index.ts +++ b/apps/server/src/modules/oauth/uc/index.ts @@ -1,2 +1 @@ -export * from './oauth.uc'; export * from './hydra-oauth.uc'; diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts deleted file mode 100644 index 1e888abd5f1..00000000000 --- a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { OauthUc } from '@modules/oauth/uc/oauth.uc'; -import { ProvisioningService } from '@modules/provisioning'; -import { ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@modules/provisioning/dto'; -import { SystemService } from '@modules/system'; -import { OauthConfigDto, SystemDto } from '@modules/system/service'; -import { UserService } from '@modules/user'; -import { UserMigrationService } from '@modules/user-login-migration'; -import { OAuthMigrationError } from '@modules/user-login-migration/error/oauth-migration.error'; -import { SchoolMigrationService } from '@modules/user-login-migration/service'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, UserDO } from '@shared/domain'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { ISession } from '@shared/domain/types/session'; -import { legacySchoolDoFactory, setupEntities } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; -import { OauthCurrentUser } from '@modules/authentication/interface'; -import { AuthorizationParams } from '../controller/dto'; -import { OAuthTokenDto } from '../interface'; -import { OAuthSSOError } from '../loggable/oauth-sso.error'; -import { OAuthProcessDto } from '../service/dto'; -import { OAuthService } from '../service/oauth.service'; -import { OauthLoginStateDto } from './dto/oauth-login-state.dto'; -import resetAllMocks = jest.resetAllMocks; - -jest.mock('nanoid', () => { - return { - nanoid: () => 'mockNanoId', - }; -}); - -describe('OAuthUc', () => { - let module: TestingModule; - let uc: OauthUc; - - let authenticationService: DeepMocked; - let oauthService: DeepMocked; - let systemService: DeepMocked; - let provisioningService: DeepMocked; - let userMigrationService: DeepMocked; - let userService: DeepMocked; - let schoolMigrationService: DeepMocked; - - beforeAll(async () => { - await setupEntities(); - - module = await Test.createTestingModule({ - providers: [ - OauthUc, - { - provide: LegacyLogger, - useValue: createMock(), - }, - { - provide: SystemService, - useValue: createMock(), - }, - { - provide: OAuthService, - useValue: createMock(), - }, - { - provide: AuthenticationService, - useValue: createMock(), - }, - { - provide: ProvisioningService, - useValue: createMock(), - }, - { - provide: UserService, - useValue: createMock(), - }, - { - provide: LegacySchoolService, - useValue: createMock(), - }, - { - provide: UserMigrationService, - useValue: createMock(), - }, - { - provide: SchoolMigrationService, - useValue: createMock(), - }, - { - provide: AuthenticationService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(OauthUc); - systemService = module.get(SystemService); - authenticationService = module.get(AuthenticationService); - oauthService = module.get(OAuthService); - provisioningService = module.get(ProvisioningService); - userService = module.get(UserService); - userMigrationService = module.get(UserMigrationService); - schoolMigrationService = module.get(SchoolMigrationService); - authenticationService = module.get(AuthenticationService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - resetAllMocks(); - }); - - const createOAuthTestData = () => { - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - return { - system, - systemId: system.id as string, - oauthConfig, - }; - }; - - describe('startOauthLogin', () => { - describe('when starting an oauth login without migration', () => { - const setup = () => { - const { system, systemId } = createOAuthTestData(); - - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - systemId, - session, - authenticationUrl, - }; - }; - - it('should return the authentication url for the system', async () => { - const { systemId, session, authenticationUrl } = setup(); - - const result: string = await uc.startOauthLogin(session, systemId, false); - - expect(result).toEqual(authenticationUrl); - }); - }); - - describe('when starting an oauth login during a migration', () => { - const setup = () => { - const { system, systemId } = createOAuthTestData(); - - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - const postLoginRedirect = 'postLoginRedirect'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - system, - systemId, - postLoginRedirect, - session, - }; - }; - - it('should save data to the session', async () => { - const { systemId, system, session, postLoginRedirect } = setup(); - - await uc.startOauthLogin(session, systemId, false, postLoginRedirect); - - expect(session.oauthLoginState).toEqual({ - systemId, - state: 'mockNanoId', - postLoginRedirect, - provider: system.oauthConfig?.provider as string, - userLoginMigration: false, - }); - }); - }); - - describe('when the system cannot be found', () => { - const setup = () => { - const { systemId, system } = createOAuthTestData(); - system.oauthConfig = undefined; - const session: DeepMocked = createMock(); - const authenticationUrl = 'authenticationUrl'; - - systemService.findById.mockResolvedValue(system); - oauthService.getAuthenticationUrl.mockReturnValue(authenticationUrl); - - return { - systemId, - session, - authenticationUrl, - }; - }; - - it('should throw UnprocessableEntityException', async () => { - const { systemId, session } = setup(); - - const func = async () => uc.startOauthLogin(session, systemId, false); - - await expect(func).rejects.toThrow(UnprocessableEntityException); - }); - }); - }); - - describe('processOAuth', () => { - const setup = () => { - const postLoginRedirect = 'postLoginRedirect'; - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - postLoginRedirect, - provider: 'mock_provider', - userLoginMigration: false, - }); - const code = 'code'; - const error = 'error'; - - const jwt = 'schulcloudJwt'; - const redirect = 'redirect'; - const user: UserDO = new UserDO({ - id: 'mockUserId', - firstName: 'firstName', - lastName: 'lastame', - email: '', - roles: [], - schoolId: 'mockSchoolId', - externalId: 'mockExternalId', - }); - - const currentUser: OauthCurrentUser = { userId: 'userId', isExternalUser: true } as OauthCurrentUser; - const testSystem: SystemDto = new SystemDto({ - id: 'mockSystemId', - type: 'mock', - oauthConfig: { provider: 'testProvider' } as OauthConfigDto, - }); - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - return { cachedState, code, error, jwt, redirect, user, currentUser, testSystem, tokenDto }; - }; - - describe('when a user is returned', () => { - it('should return a response with a valid jwt', async () => { - const { cachedState, code, error, jwt, redirect, user, currentUser, tokenDto } = setup(); - - userService.getResolvedUser.mockResolvedValue(currentUser); - authenticationService.generateJwt.mockResolvedValue({ accessToken: jwt }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ user, redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code, error); - expect(response).toEqual( - expect.objectContaining({ - jwt, - redirect, - }) - ); - }); - }); - - describe('when no user is returned', () => { - it('should return a response without a jwt', async () => { - const { cachedState, code, error, redirect, tokenDto } = setup(); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code, error); - - expect(response).toEqual({ - redirect, - }); - }); - }); - - describe('when an error occurs', () => { - it('should return an OAuthProcessDto with error', async () => { - const { cachedState, code, error, testSystem } = setup(); - oauthService.authenticateUser.mockRejectedValue(new OAuthSSOError('Testmessage')); - systemService.findById.mockResolvedValue(testSystem); - - const response = uc.processOAuthLogin(cachedState, code, error); - - await expect(response).rejects.toThrow(OAuthSSOError); - }); - }); - - describe('when the process runs successfully', () => { - it('should return a valid jwt', async () => { - const { cachedState, code, user, currentUser, jwt, redirect, tokenDto } = setup(); - - userService.getResolvedUser.mockResolvedValue(currentUser); - authenticationService.generateJwt.mockResolvedValue({ accessToken: jwt }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - oauthService.provisionUser.mockResolvedValue({ user, redirect }); - - const response: OAuthProcessDto = await uc.processOAuthLogin(cachedState, code); - - expect(response).toEqual({ - jwt, - redirect, - }); - }); - }); - }); - - describe('migration', () => { - describe('migrate', () => { - describe('when authorize user and migration was successful', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - 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/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - systemService.findById.mockResolvedValue(system); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - }; - }; - - it('should return redirect to migration succeed page', async () => { - const { query, cachedState } = setupMigration(); - - const result: MigrationDto = await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(result.redirect).toStrictEqual('https://mock.de/migration/succeed'); - }); - - it('should remove the jwt from the whitelist', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(authenticationService.removeJwtFromWhitelist).toHaveBeenCalledWith('jwt'); - }); - }); - - describe('when the jwt cannot be removed', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const oauthConfig: OauthConfigDto = new OauthConfigDto({ - clientId: '12345', - clientSecret: 'mocksecret', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'mock_authEndpoint', - provider: 'mock_provider', - logoutEndpoint: 'mock_logoutEndpoint', - issuer: 'mock_issuer', - jwksEndpoint: 'mock_jwksEndpoint', - redirectUri: 'mock_codeRedirectUri', - }); - - const system: SystemDto = new SystemDto({ - id: 'systemId', - type: 'oauth', - oauthConfig, - }); - - 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/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - const error: Error = new Error('testError'); - systemService.findById.mockResolvedValue(system); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - authenticationService.removeJwtFromWhitelist.mockRejectedValue(error); - - return { - query, - cachedState, - error, - }; - }; - - it('should throw', async () => { - const { query, error, cachedState } = setupMigration(); - - const func = () => uc.migrate('jwt', 'currentUserId', query, cachedState); - - await expect(func).rejects.toThrow(error); - }); - }); - - describe('when migration failed', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - - const userMigrationFailedDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/dashboard', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - userMigrationService.migrateUser.mockResolvedValue(userMigrationFailedDto); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - }; - }; - - it('should return redirect to dashboard ', async () => { - const { query, cachedState } = setupMigration(); - - const result: MigrationDto = await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(result.redirect).toStrictEqual('https://mock.de/dashboard'); - }); - }); - - describe('when external school and official school number is defined ', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - - return { - query, - cachedState, - oauthData, - }; - }; - - it('should call schoolToMigrate', async () => { - const { oauthData, query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.schoolToMigrate).toHaveBeenCalledWith( - 'currentUserId', - oauthData.externalSchool?.externalId, - oauthData.externalSchool?.officialSchoolNumber - ); - }); - }); - - describe('when external school and official school number is defined and school has to be migrated', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - const schoolToMigrate: LegacySchoolDo | void = legacySchoolDoFactory.build({ name: 'mockName' }); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockResolvedValue(schoolToMigrate); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - oauthData, - schoolToMigrate, - }; - }; - - it('should call migrateSchool', async () => { - const { oauthData, query, cachedState, schoolToMigrate } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.migrateSchool).toHaveBeenCalledWith( - oauthData.externalSchool?.externalId, - schoolToMigrate, - 'systemId' - ); - }); - }); - - describe('when external school and official school number is defined and school is already migrated', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - officialSchoolNumber: 'mockNumber', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockResolvedValue(null); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - }; - }; - - it('should not call migrateSchool', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.migrateSchool).not.toHaveBeenCalled(); - }); - }); - - describe('when external school is not defined', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - 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/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.authenticateUser.mockResolvedValue(tokenDto); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - return { - query, - cachedState, - }; - }; - - it('should not call schoolToMigrate', async () => { - const { query, cachedState } = setupMigration(); - - await uc.migrate('jwt', 'currentUserId', query, cachedState); - - expect(schoolMigrationService.schoolToMigrate).not.toHaveBeenCalled(); - }); - }); - - describe('when official school number is not defined', () => { - const setupMigration = () => { - const code = '43534543jnj543342jn2'; - - const query: AuthorizationParams = { code, state: 'state' }; - - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const externalUserId = 'externalUserId'; - - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.SANIS, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalSchool: { - externalId: 'mockId', - name: 'mockName', - }, - }); - - const userMigrationDto: MigrationDto = new MigrationDto({ - redirect: 'https://mock.de/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - const error = new OAuthMigrationError( - 'Official school number from target migration system is missing', - 'ext_official_school_number_missing' - ); - - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - oauthService.authenticateUser.mockResolvedValue(tokenDto); - schoolMigrationService.schoolToMigrate.mockImplementation(() => { - throw error; - }); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - - return { - query, - cachedState, - error, - }; - }; - - it('should throw OAuthMigrationError', async () => { - const { query, cachedState, error } = setupMigration(); - - await expect(uc.migrate('jwt', 'currentUserId', query, cachedState)).rejects.toThrow(error); - }); - }); - }); - - describe('when state is mismatched', () => { - const setupMigration = () => { - const cachedState: OauthLoginStateDto = new OauthLoginStateDto({ - state: 'state', - systemId: 'systemId', - provider: 'mock_provider', - userLoginMigration: true, - }); - - const query: AuthorizationParams = { state: 'failedState' }; - - 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/migration/succeed', - }); - - const tokenDto: OAuthTokenDto = new OAuthTokenDto({ - idToken: 'idToken', - refreshToken: 'refreshToken', - accessToken: 'accessToken', - }); - - oauthService.authenticateUser.mockResolvedValue(tokenDto); - userMigrationService.migrateUser.mockResolvedValue(userMigrationDto); - oauthService.requestToken.mockResolvedValue(tokenDto); - provisioningService.getData.mockResolvedValue(oauthData); - - return { - cachedState, - query, - }; - }; - - it('should throw an UnauthorizedException', async () => { - const { cachedState, query } = setupMigration(); - - const response = uc.migrate('jwt', 'currentUserId', query, cachedState); - - await expect(response).rejects.toThrow(UnauthorizedException); - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.ts b/apps/server/src/modules/oauth/uc/oauth.uc.ts deleted file mode 100644 index c495e7be05d..00000000000 --- a/apps/server/src/modules/oauth/uc/oauth.uc.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Injectable, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; -import { EntityId, LegacySchoolDo, UserDO } from '@shared/domain'; -import { ISession } from '@shared/domain/types/session'; -import { LegacyLogger } from '@src/core/logger'; -import { AuthenticationService } from '@modules/authentication/services/authentication.service'; -import { ProvisioningService } from '@modules/provisioning'; -import { OauthDataDto } from '@modules/provisioning/dto'; -import { SystemService } from '@modules/system'; -import { SystemDto } from '@modules/system/service/dto/system.dto'; -import { UserService } from '@modules/user'; -import { UserMigrationService } from '@modules/user-login-migration'; -import { SchoolMigrationService } from '@modules/user-login-migration/service'; -import { MigrationDto } from '@modules/user-login-migration/service/dto'; -import { nanoid } from 'nanoid'; -import { OauthCurrentUser } from '@modules/authentication/interface'; -import { AuthorizationParams } from '../controller/dto'; -import { OAuthTokenDto } from '../interface'; -import { OAuthProcessDto } from '../service/dto'; -import { OAuthService } from '../service/oauth.service'; -import { OauthLoginStateDto } from './dto/oauth-login-state.dto'; - -/** - * @deprecated remove after login via oauth moved to authentication module - */ -@Injectable() -export class OauthUc { - constructor( - private readonly oauthService: OAuthService, - private readonly authenticationService: AuthenticationService, - private readonly systemService: SystemService, - private readonly provisioningService: ProvisioningService, - private readonly userService: UserService, - private readonly userMigrationService: UserMigrationService, - private readonly schoolMigrationService: SchoolMigrationService, - private readonly logger: LegacyLogger - ) { - this.logger.setContext(OauthUc.name); - } - - async startOauthLogin( - session: ISession, - systemId: EntityId, - migration: boolean, - postLoginRedirect?: string - ): Promise { - const state = nanoid(16); - - const system: SystemDto = await this.systemService.findById(systemId); - if (!system.oauthConfig) { - throw new UnprocessableEntityException(`Requested system ${systemId} has no oauth configured`); - } - - const authenticationUrl: string = this.oauthService.getAuthenticationUrl(system.oauthConfig, state, migration); - - session.oauthLoginState = new OauthLoginStateDto({ - state, - systemId, - provider: system.oauthConfig.provider, - postLoginRedirect, - userLoginMigration: migration, - }); - - return authenticationUrl; - } - - async processOAuthLogin(cachedState: OauthLoginStateDto, code?: string, error?: string): Promise { - const { state, systemId, postLoginRedirect, userLoginMigration } = cachedState; - - this.logger.debug(`Oauth login process started. [state: ${state}, system: ${systemId}]`); - - const redirectUri: string = this.oauthService.getRedirectUri(userLoginMigration); - - const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(systemId, redirectUri, code, error); - - const { user, redirect }: { user?: UserDO; redirect: string } = await this.oauthService.provisionUser( - systemId, - tokenDto.idToken, - tokenDto.accessToken, - postLoginRedirect - ); - - this.logger.debug(`Generating jwt for user. [state: ${state}, system: ${systemId}]`); - - let jwt: string | undefined; - if (user && user.id) { - jwt = await this.getJwtForUser(user.id); - } - - const response = new OAuthProcessDto({ - jwt, - redirect, - }); - - return response; - } - - async migrate( - userJwt: string, - currentUserId: string, - query: AuthorizationParams, - cachedState: OauthLoginStateDto - ): Promise { - const { state, systemId, userLoginMigration } = cachedState; - - if (state !== query.state) { - throw new UnauthorizedException(`Invalid state. Got: ${query.state} Expected: ${state}`); - } - - const redirectUri: string = this.oauthService.getRedirectUri(userLoginMigration); - - const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser( - systemId, - redirectUri, - query.code, - query.error - ); - - const data: OauthDataDto = await this.provisioningService.getData(systemId, tokenDto.idToken, tokenDto.accessToken); - - if (data.externalSchool) { - const schoolToMigrate: LegacySchoolDo | null = await this.schoolMigrationService.schoolToMigrate( - currentUserId, - data.externalSchool.externalId, - data.externalSchool.officialSchoolNumber - ); - if (schoolToMigrate) { - await this.schoolMigrationService.migrateSchool(data.externalSchool.externalId, schoolToMigrate, systemId); - } - } - - const migrationDto: MigrationDto = await this.userMigrationService.migrateUser( - currentUserId, - data.externalUser.externalId, - systemId - ); - - await this.authenticationService.removeJwtFromWhitelist(userJwt); - - return migrationDto; - } - - private async getJwtForUser(userId: EntityId): Promise { - const oauthCurrentUser: OauthCurrentUser = await this.userService.getResolvedUser(userId); - - const { accessToken } = await this.authenticationService.generateJwt(oauthCurrentUser); - - return accessToken; - } -} diff --git a/config/default.schema.json b/config/default.schema.json index bd637b719e9..ae43648f3ea 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1272,11 +1272,6 @@ "default": false, "description": "Makes the new school administration page the default page" }, - "FEATURE_CLIENT_USER_LOGIN_MIGRATION_ENABLED": { - "type": "boolean", - "default": false, - "description": "Changes the schulcloud client to use new login endpoints" - }, "FEATURE_CTL_TOOLS_TAB_ENABLED": { "type": "boolean", "default": false, diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 06a54c6cf96..62615f0efb1 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -56,7 +56,6 @@ const exposedVars = [ 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED', 'MIGRATION_END_GRACE_PERIOD_MS', - 'FEATURE_CLIENT_USER_LOGIN_MIGRATION_ENABLED', 'FEATURE_CTL_TOOLS_TAB_ENABLED', 'FEATURE_LTI_TOOLS_TAB_ENABLED', 'FILES_STORAGE__MAX_FILE_SIZE', 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 13/13] 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 {}