diff --git a/apps/server/src/modules/group/entity/group-user.entity.ts b/apps/server/src/modules/group/entity/group-user.entity.ts index e202de7a400..d69ef492b25 100644 --- a/apps/server/src/modules/group/entity/group-user.entity.ts +++ b/apps/server/src/modules/group/entity/group-user.entity.ts @@ -1,5 +1,6 @@ import { Embeddable, ManyToOne } from '@mikro-orm/core'; -import { Role, User } from '@shared/domain/entity'; +import { Role } from '@shared/domain/entity/role.entity'; +import { User } from '@shared/domain/entity/user.entity'; export interface GroupUserEntityProps { user: User; diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index a8e39f9d86e..e127155fd25 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -2,13 +2,14 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { ExternalSource, UserDO } from '@shared/domain/domainobject'; -import { SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { cleanupCollections, groupEntityFactory, groupFactory, roleFactory, schoolFactory, + systemEntityFactory, userDoFactory, userFactory, } from '@shared/testing'; @@ -268,6 +269,115 @@ describe('GroupRepo', () => { }); }); + describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { + describe('when groups for the school exist', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + type: GroupEntityTypes.CLASS, + organization: school, + externalSource: { + system, + }, + }); + groups[1].type = GroupEntityTypes.COURSE; + groups[2].type = GroupEntityTypes.OTHER; + + const otherSchool: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + await em.persistAndFlush([school, system, ...groups, otherSchool, ...otherGroups]); + em.clear(); + + return { + school, + system, + otherSchool, + groups, + }; + }; + + it('should return the groups', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toHaveLength(1); + }); + + it('should only return groups from the selected school', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result.every((group) => group.organizationId === school.id)).toEqual(true); + }); + + it('should only return groups from the selected system', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result.every((group) => group.externalSource?.systemId === system.id)).toEqual(true); + }); + + it('should return only groups of the given group type', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + }); + }); + + describe('when no group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolFactory.buildWithId(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([school, system]); + em.clear(); + + return { + school, + system, + }; + }; + + it('should return an empty array', async () => { + const { school, system } = await setup(); + + const result: Group[] = await repo.findGroupsBySchoolIdAndSystemIdAndGroupType( + school.id, + system.id, + GroupTypes.CLASS + ); + + expect(result).toHaveLength(0); + }); + }); + }); + describe('save', () => { describe('when a new object is provided', () => { const setup = () => { diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index d355ee6b117..768a6f3f733 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -83,6 +83,29 @@ export class GroupRepo { return domainObjects; } + public async findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId: EntityId, + systemId: EntityId, + groupType: GroupTypes + ): Promise { + const groupEntityType: GroupEntityTypes = GroupTypesToGroupEntityTypesMapping[groupType]; + + const scope: Scope = new GroupScope() + .byOrganizationId(schoolId) + .bySystemId(systemId) + .byTypes([groupEntityType]); + + const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); + + const domainObjects: Group[] = entities.map((entity) => { + const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + + return new Group(props); + }); + + return domainObjects; + } + public async save(domainObject: Group): Promise { const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); diff --git a/apps/server/src/modules/group/repo/group.scope.spec.ts b/apps/server/src/modules/group/repo/group.scope.spec.ts index 1088651853b..28a71540e70 100644 --- a/apps/server/src/modules/group/repo/group.scope.spec.ts +++ b/apps/server/src/modules/group/repo/group.scope.spec.ts @@ -54,6 +54,32 @@ describe(GroupScope.name, () => { }); }); + describe('bySystemId', () => { + describe('when id is undefined', () => { + it('should not add query', () => { + scope.bySystemId(undefined); + + expect(scope.query).toEqual({}); + }); + }); + + describe('when id is defined', () => { + const setup = () => { + return { + id: new ObjectId().toHexString(), + }; + }; + + it('should add query', () => { + const { id } = setup(); + + scope.bySystemId(id); + + expect(scope.query).toEqual({ externalSource: { system: id } }); + }); + }); + }); + describe('byUserId', () => { describe('when id is undefined', () => { it('should not add query', () => { diff --git a/apps/server/src/modules/group/repo/group.scope.ts b/apps/server/src/modules/group/repo/group.scope.ts index be0c6938aa5..e7e0a5f7b3d 100644 --- a/apps/server/src/modules/group/repo/group.scope.ts +++ b/apps/server/src/modules/group/repo/group.scope.ts @@ -18,6 +18,13 @@ export class GroupScope extends Scope { return this; } + bySystemId(id: EntityId | undefined): this { + if (id) { + this.addQuery({ externalSource: { system: id } }); + } + return this; + } + byUserId(id: EntityId | undefined): this { if (id) { this.addQuery({ users: { user: new ObjectId(id) } }); diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 51986e983d0..3acc7dab5ca 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -217,6 +217,48 @@ describe('GroupService', () => { }); }); + describe('findGroupsBySchoolIdAndSystemIdAndGroupType', () => { + describe('when the school has groups of type class', () => { + const setup = () => { + const schoolId: string = new ObjectId().toHexString(); + const systemId: string = new ObjectId().toHexString(); + const groups: Group[] = groupFactory.buildList(3); + + groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValue(groups); + + return { + schoolId, + systemId, + groups, + }; + }; + + it('should search for the groups', async () => { + const { schoolId, systemId } = setup(); + + await service.findGroupsBySchoolIdAndSystemIdAndGroupType(schoolId, systemId, GroupTypes.CLASS); + + expect(groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolId, + systemId, + GroupTypes.CLASS + ); + }); + + it('should return the groups', async () => { + const { schoolId, systemId, groups } = setup(); + + const result: Group[] = await service.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + GroupTypes.CLASS + ); + + expect(result).toEqual(groups); + }); + }); + }); + describe('save', () => { describe('when saving a group', () => { const setup = () => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 5ac511f55b2..5dfd41256bb 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -44,6 +44,20 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } + public async findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId: EntityId, + systemId: EntityId, + groupType: GroupTypes + ): Promise { + const group: Group[] = await this.groupRepo.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + groupType + ); + + return group; + } + public async save(group: Group): Promise { const savedGroup: Group = await this.groupRepo.save(group); diff --git a/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts b/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts index 3676ce72ee2..80c059c79f8 100644 --- a/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts +++ b/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts @@ -1,4 +1,5 @@ import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningOptionsType } from './provisioning-options-type'; export abstract class BaseProvisioningOptions { public isApplicable(provisioningOptions: ProvisioningOptionsInterface): provisioningOptions is T { @@ -11,5 +12,7 @@ export abstract class BaseProvisioningOptions { + describe('getDefaultProvisioningOptions', () => { + describe('when the provisioning strategy has options', () => { + it('should have the correct options instance', () => { + const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder(SystemProvisioningStrategy.SANIS); + + const result: AnyProvisioningOptions = builder.getDefaultProvisioningOptions(); + + expect(result).toBeInstanceOf(SchulConneXProvisioningOptions); + }); + }); + + describe('when the provisioning strategy has no options', () => { + it('should throw an error', () => { + const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( + SystemProvisioningStrategy.UNDEFINED + ); + + expect(() => builder.getDefaultProvisioningOptions()).toThrow(ProvisioningStrategyNoOptionsLoggableException); + }); + }); + }); + describe('buildProvisioningOptions', () => { describe('when the provisioning strategy is "SANIS" and the options are valid', () => { const setup = () => { @@ -52,21 +77,5 @@ describe(SchoolSystemOptionsBuilder.name, () => { ).toThrow(ProvisioningStrategyInvalidOptionsLoggableException); }); }); - - describe('when the provisioning strategy has no options', () => { - it('should throw an error', () => { - const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( - SystemProvisioningStrategy.UNDEFINED - ); - - expect(() => - builder.buildProvisioningOptions({ - groupProvisioningClassesEnabled: true, - groupProvisioningCoursesEnabled: true, - groupProvisioningOtherEnabled: true, - }) - ).toThrow(ProvisioningStrategyInvalidOptionsLoggableException); - }); - }); }); }); diff --git a/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts index f431b27f5ae..8af03d85e3a 100644 --- a/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts +++ b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts @@ -1,22 +1,31 @@ import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { ProvisioningOptionsInterface } from '../interface'; -import { ProvisioningStrategyInvalidOptionsLoggableException } from '../loggable'; +import { + ProvisioningStrategyInvalidOptionsLoggableException, + ProvisioningStrategyNoOptionsLoggableException, +} from '../loggable'; import { provisioningStrategyOptions } from './provisioning-strategy-options'; import { AnyProvisioningOptions } from './school-system-options.do'; export class SchoolSystemOptionsBuilder { constructor(private readonly provisioningStrategy: SystemProvisioningStrategy) {} - public buildProvisioningOptions(provisioningOptions: ProvisioningOptionsInterface): AnyProvisioningOptions { + public getDefaultProvisioningOptions(): AnyProvisioningOptions { const ProvisioningOptionsConstructor: (new () => AnyProvisioningOptions) | undefined = provisioningStrategyOptions.get(this.provisioningStrategy); if (!ProvisioningOptionsConstructor) { - throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); + throw new ProvisioningStrategyNoOptionsLoggableException(this.provisioningStrategy); } const createdProvisioningOptions: AnyProvisioningOptions = new ProvisioningOptionsConstructor(); + return createdProvisioningOptions; + } + + public buildProvisioningOptions(provisioningOptions: ProvisioningOptionsInterface): AnyProvisioningOptions { + const createdProvisioningOptions: AnyProvisioningOptions = this.getDefaultProvisioningOptions(); + if (!createdProvisioningOptions.isApplicable(provisioningOptions)) { throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); } diff --git a/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts b/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts index 7bbcbfc48af..78f63faa6c9 100644 --- a/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts +++ b/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts @@ -1,5 +1,6 @@ import { SchulConneXProvisioningOptionsInterface } from '../interface'; import { BaseProvisioningOptions } from './base-provisioning-options'; +import { ProvisioningOptionsType } from './provisioning-options-type'; export class SchulConneXProvisioningOptions extends BaseProvisioningOptions @@ -11,6 +12,10 @@ export class SchulConneXProvisioningOptions groupProvisioningOtherEnabled = false; + get getType(): ProvisioningOptionsType { + return ProvisioningOptionsType.SCHULCONNEX; + } + set(props: SchulConneXProvisioningOptionsInterface): this { this.groupProvisioningClassesEnabled = props.groupProvisioningClassesEnabled; this.groupProvisioningCoursesEnabled = props.groupProvisioningCoursesEnabled; diff --git a/apps/server/src/modules/legacy-school/legacy-school.module.ts b/apps/server/src/modules/legacy-school/legacy-school.module.ts index 8fc5893bb42..92d028044d2 100644 --- a/apps/server/src/modules/legacy-school/legacy-school.module.ts +++ b/apps/server/src/modules/legacy-school/legacy-school.module.ts @@ -1,3 +1,4 @@ +import { GroupModule } from '@modules/group'; import { Module } from '@nestjs/common'; import { FederalStateRepo, LegacySchoolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; @@ -5,16 +6,18 @@ import { SchoolSystemOptionsRepo, SchoolYearRepo } from './repo'; import { FederalStateService, LegacySchoolService, + ProvisioningOptionsUpdateService, SchoolSystemOptionsService, SchoolValidationService, SchoolYearService, + SchulconnexProvisioningOptionsUpdateService, } from './service'; /** * @deprecated because it uses the deprecated LegacySchoolDo. */ @Module({ - imports: [LoggerModule], + imports: [LoggerModule, GroupModule], providers: [ LegacySchoolRepo, LegacySchoolService, @@ -25,7 +28,15 @@ import { SchoolValidationService, SchoolSystemOptionsRepo, SchoolSystemOptionsService, + ProvisioningOptionsUpdateService, + SchulconnexProvisioningOptionsUpdateService, + ], + exports: [ + LegacySchoolService, + SchoolYearService, + FederalStateService, + SchoolSystemOptionsService, + ProvisioningOptionsUpdateService, ], - exports: [LegacySchoolService, SchoolYearService, FederalStateService, SchoolSystemOptionsService], }) export class LegacySchoolModule {} diff --git a/apps/server/src/modules/legacy-school/loggable/index.ts b/apps/server/src/modules/legacy-school/loggable/index.ts index 5e5deb07997..8288e746eb4 100644 --- a/apps/server/src/modules/legacy-school/loggable/index.ts +++ b/apps/server/src/modules/legacy-school/loggable/index.ts @@ -2,3 +2,4 @@ export { SchoolNumberDuplicateLoggableException } from './school-number-duplicat export { ProvisioningStrategyInvalidOptionsLoggableException } from './provisioning-strategy-invalid-options.loggable-exception'; export { ProvisioningStrategyMissingLoggableException } from './provisioning-strategy-missing.loggable-exception'; export { ProvisioningOptionsInvalidTypeLoggableException } from './provisioning-options-invalid-type.loggable-exception'; +export { ProvisioningStrategyNoOptionsLoggableException } from './provisioning-strategy-no-options.loggable-exception'; diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts new file mode 100644 index 00000000000..8be361e1f0b --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.spec.ts @@ -0,0 +1,21 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ProvisioningStrategyNoOptionsLoggableException } from './provisioning-strategy-no-options.loggable-exception'; + +describe(ProvisioningStrategyNoOptionsLoggableException.name, () => { + describe('getLogMessage', () => { + it('should log the correct message', () => { + const exception = new ProvisioningStrategyNoOptionsLoggableException(SystemProvisioningStrategy.SANIS); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'PROVISIONING_STRATEGY_NO_OPTIONS', + message: expect.any(String), + stack: expect.any(String), + data: { + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts new file mode 100644 index 00000000000..69a1726459c --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-no-options.loggable-exception.ts @@ -0,0 +1,20 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ProvisioningStrategyNoOptionsLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly provisioningStrategy: SystemProvisioningStrategy) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PROVISIONING_STRATEGY_NO_OPTIONS', + message: 'The provisioning strategy does not support options.', + stack: this.stack, + data: { + provisioningStrategy: this.provisioningStrategy, + }, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/service/index.ts b/apps/server/src/modules/legacy-school/service/index.ts index 65307ab17f0..3df9f66017d 100644 --- a/apps/server/src/modules/legacy-school/service/index.ts +++ b/apps/server/src/modules/legacy-school/service/index.ts @@ -3,3 +3,5 @@ export * from './school-year.service'; export * from './federal-state.service'; export * from './validation'; export { SchoolSystemOptionsService } from './school-system-options.service'; +export { ProvisioningOptionsUpdateService } from './provisioning-options-update.service'; +export { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts new file mode 100644 index 00000000000..1a13b81b7dc --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update-handler.ts @@ -0,0 +1,6 @@ +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions } from '../domain'; + +export interface ProvisioningOptionsUpdateHandler { + handleUpdate(schoolId: EntityId, systemId: EntityId, newOptions: T, oldOptions: T): Promise; +} diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts new file mode 100644 index 00000000000..45092fbd9c3 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.spec.ts @@ -0,0 +1,66 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from '../domain'; +import { ProvisioningOptionsUpdateService } from './provisioning-options-update.service'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +describe(ProvisioningOptionsUpdateService.name, () => { + let module: TestingModule; + let service: ProvisioningOptionsUpdateService; + + let schulconnexProvisioningOptionsUpdateService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + ProvisioningOptionsUpdateService, + { + provide: SchulconnexProvisioningOptionsUpdateService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(ProvisioningOptionsUpdateService); + schulconnexProvisioningOptionsUpdateService = module.get(SchulconnexProvisioningOptionsUpdateService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handleUpdate', () => { + describe('when the options are of type schulconnex', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + const provisioningOptions: SchulConneXProvisioningOptions = new SchulConneXProvisioningOptions(); + + return { + schoolId, + systemId, + provisioningOptions, + }; + }; + + it('should execute the schulconnex service', async () => { + const { schoolId, systemId, provisioningOptions } = setup(); + + await service.handleUpdate(schoolId, systemId, provisioningOptions, provisioningOptions); + + expect(schulconnexProvisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolId, + systemId, + provisioningOptions, + provisioningOptions + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts new file mode 100644 index 00000000000..7bfa12ac81f --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/provisioning-options-update.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions, ProvisioningOptionsType } from '../domain'; +import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +@Injectable() +export class ProvisioningOptionsUpdateService { + private readonly updateServices = new Map(); + + constructor( + private readonly schulconnexProvisioningOptionsUpdateService: SchulconnexProvisioningOptionsUpdateService + ) { + this.updateServices.set(ProvisioningOptionsType.SCHULCONNEX, this.schulconnexProvisioningOptionsUpdateService); + } + + public async handleUpdate( + schoolId: EntityId, + systemId: EntityId, + newOptions: AnyProvisioningOptions, + oldOptions: AnyProvisioningOptions + ): Promise { + const updater: ProvisioningOptionsUpdateHandler | undefined = this.updateServices.get(oldOptions.getType); + + if (updater) { + await updater.handleUpdate(schoolId, systemId, newOptions, oldOptions); + } + } +} diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts new file mode 100644 index 00000000000..57f2c9387a4 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.spec.ts @@ -0,0 +1,209 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { Test, TestingModule } from '@nestjs/testing'; +import { groupFactory, schoolSystemOptionsFactory } from '@shared/testing'; +import { SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; +import { SchulconnexProvisioningOptionsUpdateService } from './schulconnex-provisioning-options-update.service'; + +describe(SchulconnexProvisioningOptionsUpdateService.name, () => { + let module: TestingModule; + let service: SchulconnexProvisioningOptionsUpdateService; + + let groupService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchulconnexProvisioningOptionsUpdateService, + { + provide: GroupService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexProvisioningOptionsUpdateService); + groupService = module.get(GroupService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handleUpdate', () => { + describe('when groupProvisioningClassesEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + const group: Group = groupFactory.build({ type: GroupTypes.CLASS }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all classes of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.CLASS + ); + }); + + it('should delete all classes', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + + describe('when groupProvisioningCoursesEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: false, + }); + const group: Group = groupFactory.build({ type: GroupTypes.COURSE }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all courses of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.COURSE + ); + }); + + it('should delete all courses', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + + describe('when groupProvisioningOtherEnabled gets turned off', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }), + }); + const newProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: false, + groupProvisioningCoursesEnabled: true, + }); + const group: Group = groupFactory.build({ type: GroupTypes.OTHER }); + + groupService.findGroupsBySchoolIdAndSystemIdAndGroupType.mockResolvedValueOnce([group]); + + return { + schoolSystemOptions, + newProvisioningOptions, + group, + }; + }; + + it('should search for all other groups of the school for the system', async () => { + const { schoolSystemOptions, newProvisioningOptions } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.findGroupsBySchoolIdAndSystemIdAndGroupType).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + GroupTypes.OTHER + ); + }); + + it('should delete all other groups', async () => { + const { schoolSystemOptions, newProvisioningOptions, group } = setup(); + + await service.handleUpdate( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newProvisioningOptions, + schoolSystemOptions.provisioningOptions + ); + + expect(groupService.delete).toHaveBeenCalledTimes(1); + expect(groupService.delete).toHaveBeenCalledWith(group); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts new file mode 100644 index 00000000000..f6494fdd951 --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts @@ -0,0 +1,45 @@ +import { Group, GroupService, GroupTypes } from '@modules/group'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from '../domain'; +import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; + +@Injectable() +export class SchulconnexProvisioningOptionsUpdateService + implements ProvisioningOptionsUpdateHandler +{ + constructor(private readonly groupService: GroupService) {} + + public async handleUpdate( + schoolId: EntityId, + systemId: EntityId, + newOptions: SchulConneXProvisioningOptions, + oldOptions: SchulConneXProvisioningOptions + ): Promise { + if (oldOptions.groupProvisioningClassesEnabled && !newOptions.groupProvisioningClassesEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.CLASS); + } + + if (oldOptions.groupProvisioningCoursesEnabled && !newOptions.groupProvisioningCoursesEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.COURSE); + } + + if (oldOptions.groupProvisioningOtherEnabled && !newOptions.groupProvisioningOtherEnabled) { + await this.deleteGroups(schoolId, systemId, GroupTypes.OTHER); + } + } + + private async deleteGroups(schoolId: EntityId, systemId: EntityId, groupType: GroupTypes): Promise { + const groups: Group[] = await this.groupService.findGroupsBySchoolIdAndSystemIdAndGroupType( + schoolId, + systemId, + groupType + ); + + await Promise.all( + groups.map(async (group: Group): Promise => { + await this.groupService.delete(group); + }) + ); + } +} diff --git a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts index 21312bc9988..c235b96f77b 100644 --- a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts @@ -7,9 +7,9 @@ import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Permission } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { schoolSystemOptionsFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; -import { AnyProvisioningOptions, SchoolSystemOptions } from '../domain'; +import { AnyProvisioningOptions, SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; import { ProvisioningStrategyMissingLoggableException } from '../loggable'; -import { SchoolSystemOptionsService } from '../service'; +import { ProvisioningOptionsUpdateService, SchoolSystemOptionsService } from '../service'; import { SchoolSystemOptionsUc } from './school-system-options.uc'; describe(SchoolSystemOptionsUc.name, () => { @@ -19,6 +19,7 @@ describe(SchoolSystemOptionsUc.name, () => { let authorizationService: DeepMocked; let systemService: DeepMocked; let schoolSystemOptionsService: DeepMocked; + let provisioningOptionsUpdateService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -38,6 +39,10 @@ describe(SchoolSystemOptionsUc.name, () => { provide: SchoolSystemOptionsService, useValue: createMock(), }, + { + provide: ProvisioningOptionsUpdateService, + useValue: createMock(), + }, ], }).compile(); @@ -45,6 +50,7 @@ describe(SchoolSystemOptionsUc.name, () => { authorizationService = module.get(AuthorizationService); systemService = module.get(SystemService); schoolSystemOptionsService = module.get(SchoolSystemOptionsService); + provisioningOptionsUpdateService = module.get(ProvisioningOptionsUpdateService); }); afterAll(async () => { @@ -124,6 +130,11 @@ describe(SchoolSystemOptionsUc.name, () => { const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: system.id, + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }), }); systemService.findById.mockResolvedValueOnce(system); @@ -154,6 +165,24 @@ describe(SchoolSystemOptionsUc.name, () => { ); }); + it('should execute additional update actions', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(provisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions, + new SchulConneXProvisioningOptions() + ); + }); + it('should save the options', async () => { const { user, schoolSystemOptions } = setup(); @@ -189,60 +218,93 @@ describe(SchoolSystemOptionsUc.name, () => { const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: system.id, + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }), + }); + const newOptions: AnyProvisioningOptions = new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, }); systemService.findById.mockResolvedValueOnce(system); schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(schoolSystemOptions); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - schoolSystemOptionsService.save.mockResolvedValueOnce(schoolSystemOptions); + schoolSystemOptionsService.save.mockResolvedValueOnce( + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }) + ); return { user, schoolSystemOptions, + newOptions, }; }; it('should check the permissions', async () => { - const { user, schoolSystemOptions } = setup(); + const { user, schoolSystemOptions, newOptions } = setup(); await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, - schoolSystemOptions.provisioningOptions + newOptions ); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, - schoolSystemOptions, + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }), AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) ); }); - it('should save the options', async () => { - const { user, schoolSystemOptions } = setup(); + it('should execute additional update actions', async () => { + const { user, schoolSystemOptions, newOptions } = setup(); await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, + newOptions + ); + + expect(provisioningOptionsUpdateService.handleUpdate).toHaveBeenCalledWith( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newOptions, schoolSystemOptions.provisioningOptions ); + }); - expect(schoolSystemOptionsService.save).toHaveBeenCalledWith(schoolSystemOptions); + it('should save the options', async () => { + const { user, schoolSystemOptions, newOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + newOptions + ); + + expect(schoolSystemOptionsService.save).toHaveBeenCalledWith( + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), provisioningOptions: newOptions }) + ); }); it('should return the options', async () => { - const { user, schoolSystemOptions } = setup(); + const { user, schoolSystemOptions, newOptions } = setup(); const result: AnyProvisioningOptions = await uc.createOrUpdateProvisioningOptions( user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId, - schoolSystemOptions.provisioningOptions + newOptions ); - expect(result).toEqual(schoolSystemOptions.provisioningOptions); + expect(result).toEqual(newOptions); }); }); diff --git a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts index ad4e0d1da59..7f8e6b57dec 100644 --- a/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts @@ -8,14 +8,15 @@ import { EntityId } from '@shared/domain/types'; import { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsBuilder } from '../domain'; import { ProvisioningOptionsInterface } from '../interface'; import { ProvisioningStrategyMissingLoggableException } from '../loggable'; -import { SchoolSystemOptionsService } from '../service'; +import { ProvisioningOptionsUpdateService, SchoolSystemOptionsService } from '../service'; @Injectable() export class SchoolSystemOptionsUc { constructor( private readonly authorizationService: AuthorizationService, private readonly systemService: SystemService, - private readonly schoolSystemOptionsService: SchoolSystemOptionsService + private readonly schoolSystemOptionsService: SchoolSystemOptionsService, + private readonly provisioningOptionsUpdateService: ProvisioningOptionsUpdateService ) {} public async getProvisioningOptions( @@ -56,9 +57,12 @@ export class SchoolSystemOptionsUc { throw new ProvisioningStrategyMissingLoggableException(systemId); } - const provisioningOptions: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + const schoolSystemOptionsBuilder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder( system.provisioningStrategy - ).buildProvisioningOptions(requestedProvisioningOptions); + ); + + const newProvisioningOptions: AnyProvisioningOptions = + schoolSystemOptionsBuilder.buildProvisioningOptions(requestedProvisioningOptions); const existingSchoolSystemOptions: SchoolSystemOptions | null = await this.schoolSystemOptionsService.findBySchoolIdAndSystemId(schoolId, systemId); @@ -67,7 +71,7 @@ export class SchoolSystemOptionsUc { id: existingSchoolSystemOptions?.id ?? new ObjectId().toHexString(), systemId, schoolId, - provisioningOptions, + provisioningOptions: newProvisioningOptions, }); const user = await this.authorizationService.getUserWithPermissions(userId); @@ -77,6 +81,15 @@ export class SchoolSystemOptionsUc { AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) ); + const currentProvisioningOptions: AnyProvisioningOptions = + existingSchoolSystemOptions?.provisioningOptions ?? schoolSystemOptionsBuilder.getDefaultProvisioningOptions(); + await this.provisioningOptionsUpdateService.handleUpdate( + schoolId, + systemId, + newProvisioningOptions, + currentProvisioningOptions + ); + const savedSchoolSystemOptions: SchoolSystemOptions = await this.schoolSystemOptionsService.save( schoolSystemOptions ); diff --git a/apps/server/src/modules/provisioning/config/provisioning-config.ts b/apps/server/src/modules/provisioning/config/provisioning-config.ts index 4f6d83fc19d..b36e52ea050 100644 --- a/apps/server/src/modules/provisioning/config/provisioning-config.ts +++ b/apps/server/src/modules/provisioning/config/provisioning-config.ts @@ -4,12 +4,10 @@ export const ProvisioningFeatures = Symbol('ProvisioningFeatures'); export interface IProvisioningFeatures { schulconnexGroupProvisioningEnabled: boolean; - provisioningOptionsEnabled: boolean; } export class ProvisioningConfiguration { static provisioningFeatures: IProvisioningFeatures = { schulconnexGroupProvisioningEnabled: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, - provisioningOptionsEnabled: Configuration.get('FEATURE_PROVISIONING_OPTIONS_ENABLED') as boolean, }; } diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts index 22f702f2b1d..47068ab32e2 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts @@ -4,15 +4,14 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { legacySchoolDoFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import jwt from 'jsonwebtoken'; +import { legacySchoolDoFactory, userDoFactory } from '@shared/testing'; import { IdTokenExtractionFailureLoggableException, IdTokenUserNotFoundLoggableException, } from '@src/modules/oauth/loggable'; +import jwt from 'jsonwebtoken'; import { RoleDto } from '../../../role/service/dto/role.dto'; import { ExternalSchoolDto, @@ -34,7 +33,6 @@ describe('IservProvisioningStrategy', () => { let userService: DeepMocked; beforeAll(async () => { - await setupEntities(); module = await Test.createTestingModule({ providers: [ IservProvisioningStrategy, @@ -141,9 +139,9 @@ describe('IservProvisioningStrategy', () => { it('should throw an error with code sso_user_notfound and additional information', async () => { const { input, userUUID, email } = setup(); const schoolId: string = new ObjectId().toHexString(); - const user: User = userFactory.buildWithId({ + const user: UserDO = userDoFactory.buildWithId({ externalId: userUUID, - school: schoolFactory.buildWithId(undefined, schoolId), + schoolId, }); jest.spyOn(jwt, 'decode').mockImplementation(() => { diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts index 9567bcb4664..841daaee78e 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts @@ -1,15 +1,14 @@ import { LegacySchoolService } from '@modules/legacy-school'; +import { + IdTokenExtractionFailureLoggableException, + IdTokenUserNotFoundLoggableException, +} from '@modules/oauth/loggable'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { - IdTokenExtractionFailureLoggableException, - IdTokenUserNotFoundLoggableException, -} from '@modules/oauth/loggable'; import { ExternalSchoolDto, ExternalUserDto, @@ -66,10 +65,10 @@ export class IservProvisioningStrategy extends ProvisioningStrategy { async getAdditionalErrorInfo(email: string | undefined): Promise { if (email) { - const usersWithEmail: User[] = await this.userService.findByEmail(email); + const usersWithEmail: UserDO[] = await this.userService.findByEmail(email); if (usersWithEmail.length > 0) { - const user: User = usersWithEmail[0]; - return ` [schoolId: ${user.school.id}, currentLdapId: ${user.externalId ?? ''}]`; + const user: UserDO = usersWithEmail[0]; + return ` [schoolId: ${user.schoolId}, currentLdapId: ${user.externalId ?? ''}]`; } } return ''; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts index 80c078b52dd..7b6722f735c 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts @@ -65,7 +65,6 @@ describe('OidcStrategy', () => { beforeEach(() => { Object.assign>(provisioningFeatures, { schulconnexGroupProvisioningEnabled: false, - provisioningOptionsEnabled: false, }); }); @@ -194,7 +193,6 @@ describe('OidcStrategy', () => { describe('when group data is provided and the feature is enabled', () => { const setup = () => { provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - provisioningFeatures.provisioningOptionsEnabled = true; const externalUserId = 'externalUserId'; const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts index 6fe09cd357c..e1e2f33e2f1 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -36,9 +36,7 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { if (data.externalGroups) { let groups: ExternalGroupDto[] = data.externalGroups; - if (this.provisioningFeatures.provisioningOptionsEnabled) { - groups = await this.oidcProvisioningService.filterExternalGroups(groups, school?.id, data.system.systemId); - } + groups = await this.oidcProvisioningService.filterExternalGroups(groups, school?.id, data.system.systemId); await Promise.all( groups.map((group: ExternalGroupDto) => diff --git a/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts index 248ba25913f..fd94f69d66e 100644 --- a/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-login-migration-revert.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { LegacySchoolService } from '@modules/legacy-school'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolFeature } from '@shared/domain/types'; -import { setupEntities, userLoginMigrationDOFactory } from '@shared/testing'; +import { userLoginMigrationDOFactory } from '@shared/testing'; import { UserLoginMigrationRevertService } from './user-login-migration-revert.service'; import { UserLoginMigrationService } from './user-login-migration.service'; @@ -14,8 +14,6 @@ describe('UserLoginMigrationRevertService', () => { let userLoginMigrationService: DeepMocked; beforeAll(async () => { - await setupEntities(); - module = await Test.createTestingModule({ providers: [ UserLoginMigrationRevertService, 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 c7223c45322..310f5ccdfef 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -1,5 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AccountDto, AccountService } from '@modules/account'; import { OauthCurrentUser } from '@modules/authentication/interface'; import { RoleService } from '@modules/role'; @@ -12,7 +13,6 @@ import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { ObjectId } from '@mikro-orm/mongodb'; import { UserDto } from '../uc/dto/user.dto'; import { UserQuery } from './user-query.type'; import { UserService } from './user.service'; @@ -346,11 +346,11 @@ describe('UserService', () => { describe('findByEmail is called', () => { describe('when a user with this email exists', () => { it('should return the user', async () => { - const user: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId(); - userRepo.findByEmail.mockResolvedValue([user]); + userDORepo.findByEmail.mockResolvedValue([user]); - const result: User[] = await service.findByEmail(user.email); + const result: UserDO[] = await service.findByEmail(user.email); expect(result).toEqual([user]); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 4e8f2fe2394..ce2fe2316fa 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -90,8 +90,8 @@ export class UserService { return user; } - async findByEmail(email: string): Promise { - const user: Promise = this.userRepo.findByEmail(email); + async findByEmail(email: string): Promise { + const user: Promise = this.userDORepo.findByEmail(email); return user; } diff --git a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts index 6fdbe820dc7..baa3efbcd89 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts @@ -181,6 +181,51 @@ describe('UserRepo', () => { }); }); + describe('findByEmail', () => { + it('should find user by email', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + const result = await repo.findByEmail('USER@EXAMPLE.COM'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + }); + + it('should find user by email, ignoring case', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + let result: UserDO[]; + + result = await repo.findByEmail('USER@example.COM'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + + result = await repo.findByEmail('user@example.com'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ email: originalUsername })); + }); + + it('should not find by wildcard', async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const user = userFactory.build({ email: originalUsername }); + await em.persistAndFlush([user]); + em.clear(); + + let result: UserDO[]; + + result = await repo.findByEmail('USER@EXAMPLECCOM'); + expect(result).toHaveLength(0); + + result = await repo.findByEmail('.*'); + expect(result).toHaveLength(0); + }); + }); + describe('mapEntityToDO', () => { it('should return a domain object', () => { const id = new ObjectId(); diff --git a/apps/server/src/shared/repo/user/user-do.repo.ts b/apps/server/src/shared/repo/user/user-do.repo.ts index 1ab196fc3b5..3e363f03120 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.ts @@ -90,6 +90,17 @@ export class UserDORepo extends BaseDORepo { return userDo; } + async findByEmail(email: string): Promise { + // find mail case-insensitive by regex + const userEntitys: User[] = await this._em.find(User, { + email: new RegExp(`^${email.replace(/\W/g, '\\$&')}$`, 'i'), + }); + + const userDos: UserDO[] = userEntitys.map((userEntity: User): UserDO => this.mapEntityToDO(userEntity)); + + return userDos; + } + mapEntityToDO(entity: User): UserDO { const user: UserDO = new UserDO({ id: entity.id, diff --git a/backup/setup/schools.json b/backup/setup/schools.json index 29ce522fe58..a6382fa5381 100644 --- a/backup/setup/schools.json +++ b/backup/setup/schools.json @@ -193,7 +193,9 @@ "$oid": "5fa318f2b229544f2c697a56" }, "documentBaseDirType": "", - "systems": [], + "systems": [{ + "$oid": "62c7f233f35a554ba3ed0000" + }], "experimental": false, "pilot": false, "features": [ diff --git a/config/default.schema.json b/config/default.schema.json index 4db273ea7a0..c45f1e86ccb 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1376,11 +1376,6 @@ "default": false, "description": "Enables external tools on the column board" }, - "FEATURE_PROVISIONING_OPTIONS_ENABLED": { - "type": "boolean", - "default": false, - "description": "enables to view the page to set group provisioning options for a school and system pair" - }, "CTL_TOOLS": { "type": "object", "description": "CTL Tools properties", diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index 13980110176..b76ba885aa0 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -65,7 +65,6 @@ const exposedVars = [ 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', 'FEATURE_TLDRAW_ENABLED', - 'FEATURE_PROVISIONING_OPTIONS_ENABLED', ]; /**