From 071a0ef31d27a852d9636d46e2c3f25b2a7a76c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Fri, 15 Dec 2023 14:02:04 +0100 Subject: [PATCH 1/4] N21-1479 System provisioning options for schools (#4631) * add FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED * migration script and provisioning impl * add remove cascade *add seed data --------- Co-authored-by: Igor Richter Co-authored-by: Arne Gnisa Co-authored-by: Igor Richter <93926487+IgorCapCoder@users.noreply.github.com> --- .../templates/configmap_file_init.yml.j2 | 9 +- apps/server/src/modules/account/index.ts | 2 +- .../src/modules/account/services/index.ts | 2 +- .../authorization/authorization.module.ts | 2 + .../authorization/domain/rules/index.ts | 1 + .../rules/school-system-options.rule.spec.ts | 222 ++++++++++++ .../rules/school-system-options.rule.ts | 28 ++ .../domain/service/rule-manager.spec.ts | 8 + .../domain/service/rule-manager.ts | 5 +- .../board/service/column-board.service.ts | 2 +- .../src/modules/class/repo/classes.repo.ts | 2 +- .../modules/group/service/group.service.ts | 2 +- .../src/modules/group/uc/group.uc.spec.ts | 2 +- apps/server/src/modules/group/uc/group.uc.ts | 2 +- apps/server/src/modules/group/util/index.ts | 1 - .../controller/api-test/school.api.spec.ts | 236 ++++++++++++ .../legacy-school/controller/dto/index.ts | 6 + .../request/provisioning-options.params.ts | 16 + .../dto/request/school-system.params.ts | 13 + ...schulconnex-provisioning-options.params.ts | 13 + .../any-provisioning-options.response.ts | 3 + ...hulconnex-provisioning-options.response.ts | 19 + .../modules/legacy-school/controller/index.ts | 1 + .../school-system-options.mapper.ts | 14 + .../controller/school.controller.ts | 98 +++++ .../domain/base-provisioning-options.ts | 15 + .../src/modules/legacy-school/domain/index.ts | 4 + .../domain/provisioning-strategy-options.ts | 7 + .../school-system-options.builder.spec.ts | 72 ++++ .../domain/school-system-options.builder.ts | 28 ++ .../domain/school-system-options.do.ts | 29 ++ .../schulconnex-provisionin-options.do.ts | 21 ++ .../src/modules/legacy-school/entity/index.ts | 2 + .../entity/provisioning-options.entity.ts | 20 + .../entity/school-system-options.entity.ts | 40 ++ .../server/src/modules/legacy-school/index.ts | 7 + .../any-provisioning-options-interface.ts | 3 + .../modules/legacy-school/interface/index.ts | 3 + .../provisioning-options-interface.ts | 5 + ...ulconnex-provisioning-options-interface.ts | 8 + .../legacy-school/legacy-school.api-module.ts | 13 + .../legacy-school/legacy-school.module.ts | 14 +- .../modules/legacy-school/loggable/index.ts | 5 +- ...ns-invalid-type.loggable-exception.spec.ts | 42 +++ ...options-invalid-type.loggable-exception.ts | 27 ++ ...invalid-options.loggable-exception.spec.ts | 39 ++ ...tegy-invalid-options.loggable-exception.ts | 28 ++ ...trategy-missing.loggable-exception.spec.ts | 33 ++ ...ing-strategy-missing.loggable-exception.ts | 20 + .../src/modules/legacy-school/repo/index.ts | 1 + .../repo/school-system-options-repo.mapper.ts | 36 ++ .../repo/school-system-options.repo.spec.ts | 271 ++++++++++++++ .../repo/school-system-options.repo.ts | 94 +++++ .../modules/legacy-school/service/index.ts | 1 + .../school-system-options.service.spec.ts | 182 ++++++++++ .../service/school-system-options.service.ts | 47 +++ .../src/modules/legacy-school/uc/index.ts | 1 + .../uc/school-system-options.uc.spec.ts | 342 ++++++++++++++++++ .../uc/school-system-options.uc.ts | 86 +++++ .../src/modules/provisioning/config/index.ts | 1 + .../config/provisioning-config.ts | 15 + .../provisioning-config.module.ts | 13 + .../provisioning/provisioning.module.ts | 20 +- .../strategy/oidc/oidc.strategy.spec.ts | 32 +- .../strategy/oidc/oidc.strategy.ts | 27 +- .../service/oidc-provisioning.service.spec.ts | 228 +++++++++++- .../oidc/service/oidc-provisioning.service.ts | 76 +++- .../strategy/sanis/sanis.strategy.spec.ts | 18 +- .../strategy/sanis/sanis.strategy.ts | 13 +- .../service/feathers-roster.service.spec.ts | 16 +- .../service/feathers-roster.service.ts | 8 +- .../src/modules/pseudonym/uc/pseudonym.uc.ts | 2 +- .../src/modules/server/server.module.ts | 4 +- .../src/modules/system/domain/system.do.ts | 4 + .../server/src/modules/system/uc/system.uc.ts | 2 +- .../uc/user-login-migration.uc.ts | 2 +- .../not-found.loggable-exception.spec.ts | 15 +- .../not-found.loggable-exception.ts | 8 +- apps/server/src/shared/common/utils/index.ts | 1 + .../common/utils}/sort-helper.spec.ts | 2 +- .../common/utils}/sort-helper.ts | 4 +- .../src/shared/domain/entity/all-entities.ts | 4 +- .../src/shared/domain/entity/school.entity.ts | 6 + .../src/shared/domain/entity/system.entity.ts | 32 +- .../domain/interface/permission.enum.ts | 2 + .../legacy-system.repo.integration.spec.ts | 22 -- .../testing/factory/domainobject/index.ts | 1 + .../school-system-options.factory.ts | 24 ++ .../src/shared/testing/factory/index.ts | 1 + .../school-system-options-entity.factory.ts | 20 + .../shared/testing/user-role-permissions.ts | 2 + backup/setup/migrations.json | 11 + backup/setup/roles.json | 4 +- backup/setup/school-system-options.json | 1 + backup/setup/systems.json | 10 + config/default.schema.json | 5 + ...8347346-add-school-system-view-and-edit.js | 62 ++++ src/services/config/publicAppConfigService.js | 1 + 98 files changed, 2842 insertions(+), 132 deletions(-) create mode 100644 apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts create mode 100644 apps/server/src/modules/authorization/domain/rules/school-system-options.rule.ts delete mode 100644 apps/server/src/modules/group/util/index.ts create mode 100644 apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/index.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/request/provisioning-options.params.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/request/school-system.params.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/request/schulconnex-provisioning-options.params.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/response/any-provisioning-options.response.ts create mode 100644 apps/server/src/modules/legacy-school/controller/dto/response/schulconnex-provisioning-options.response.ts create mode 100644 apps/server/src/modules/legacy-school/controller/index.ts create mode 100644 apps/server/src/modules/legacy-school/controller/school-system-options.mapper.ts create mode 100644 apps/server/src/modules/legacy-school/controller/school.controller.ts create mode 100644 apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts create mode 100644 apps/server/src/modules/legacy-school/domain/index.ts create mode 100644 apps/server/src/modules/legacy-school/domain/provisioning-strategy-options.ts create mode 100644 apps/server/src/modules/legacy-school/domain/school-system-options.builder.spec.ts create mode 100644 apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts create mode 100644 apps/server/src/modules/legacy-school/domain/school-system-options.do.ts create mode 100644 apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts create mode 100644 apps/server/src/modules/legacy-school/entity/index.ts create mode 100644 apps/server/src/modules/legacy-school/entity/provisioning-options.entity.ts create mode 100644 apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts create mode 100644 apps/server/src/modules/legacy-school/interface/any-provisioning-options-interface.ts create mode 100644 apps/server/src/modules/legacy-school/interface/index.ts create mode 100644 apps/server/src/modules/legacy-school/interface/provisioning-options-interface.ts create mode 100644 apps/server/src/modules/legacy-school/interface/schulconnex-provisioning-options-interface.ts create mode 100644 apps/server/src/modules/legacy-school/legacy-school.api-module.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.ts create mode 100644 apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts create mode 100644 apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts create mode 100644 apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts create mode 100644 apps/server/src/modules/legacy-school/service/school-system-options.service.spec.ts create mode 100644 apps/server/src/modules/legacy-school/service/school-system-options.service.ts create mode 100644 apps/server/src/modules/legacy-school/uc/index.ts create mode 100644 apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts create mode 100644 apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts create mode 100644 apps/server/src/modules/provisioning/config/index.ts create mode 100644 apps/server/src/modules/provisioning/config/provisioning-config.ts create mode 100644 apps/server/src/modules/provisioning/provisioning-config.module.ts rename apps/server/src/{modules/group/util => shared/common/utils}/sort-helper.spec.ts (97%) rename apps/server/src/{modules/group/util => shared/common/utils}/sort-helper.ts (86%) create mode 100644 apps/server/src/shared/testing/factory/domainobject/school-system-options/school-system-options.factory.ts create mode 100644 apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts create mode 100644 backup/setup/school-system-options.json create mode 100644 migrations/1702288347346-add-school-system-view-and-edit.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 461232013b0..1b430280d7e 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 @@ -155,9 +155,14 @@ data: SANIS_CLIENT_SECRET=$(node scripts/secret.js -s $AES_KEY -e $SANIS_CLIENT_SECRET) SANIS_SYSTEM_ID=0000d186816abba584714c93 if [[ $SC_THEME == "n21" ]]; then - mongosh $DATABASE__URL --quiet --eval 'db.schools.updateOne( + mongosh $DATABASE__URL --quiet --eval 'db.schools.updateMany( { - "_id": ObjectId("5f2987e020834114b8efd6f8") + "_id": { + $in: [ + ObjectId("5f2987e020834114b8efd6f8"), + ObjectId("5fa2c5ccb229544f2c69666c") + ] + } }, { $set: { "systems" : [ ObjectId("'$SANIS_SYSTEM_ID'") ] } diff --git a/apps/server/src/modules/account/index.ts b/apps/server/src/modules/account/index.ts index 7db4a66a657..1cd85dd7252 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, AccountDto } from './services'; +export { AccountService, AccountDto, AccountSaveDto } from './services'; diff --git a/apps/server/src/modules/account/services/index.ts b/apps/server/src/modules/account/services/index.ts index 2fc3f3a3246..8a864a874e8 100644 --- a/apps/server/src/modules/account/services/index.ts +++ b/apps/server/src/modules/account/services/index.ts @@ -1,2 +1,2 @@ export * from './account.service'; -export { AccountDto } from './dto'; +export { AccountDto, AccountSaveDto } from './dto'; diff --git a/apps/server/src/modules/authorization/authorization.module.ts b/apps/server/src/modules/authorization/authorization.module.ts index ea4f5d178fe..983ed21f669 100644 --- a/apps/server/src/modules/authorization/authorization.module.ts +++ b/apps/server/src/modules/authorization/authorization.module.ts @@ -12,6 +12,7 @@ import { LegacySchoolRule, LessonRule, SchoolExternalToolRule, + SchoolSystemOptionsRule, SubmissionRule, SystemRule, TaskRule, @@ -45,6 +46,7 @@ import { FeathersAuthorizationService, FeathersAuthProvider } from './feathers'; UserLoginMigrationRule, LegacySchoolRule, SystemRule, + SchoolSystemOptionsRule, ], exports: [FeathersAuthorizationService, AuthorizationService, SystemRule], }) diff --git a/apps/server/src/modules/authorization/domain/rules/index.ts b/apps/server/src/modules/authorization/domain/rules/index.ts index 8a4f1df5109..52c3482a603 100644 --- a/apps/server/src/modules/authorization/domain/rules/index.ts +++ b/apps/server/src/modules/authorization/domain/rules/index.ts @@ -16,3 +16,4 @@ export * from './user-login-migration.rule'; export * from './user.rule'; export * from './group.rule'; export { SystemRule } from './system.rule'; +export { SchoolSystemOptionsRule } from './school-system-options.rule'; diff --git a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts new file mode 100644 index 00000000000..8f35bf0d1ee --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts @@ -0,0 +1,222 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { SchoolSystemOptions } from '@modules/legacy-school'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { + schoolFactory, + schoolSystemOptionsFactory, + setupEntities, + systemEntityFactory, + userFactory, +} from '@shared/testing'; +import { AuthorizationContextBuilder } from '../mapper'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { SchoolSystemOptionsRule } from './school-system-options.rule'; + +describe(SchoolSystemOptionsRule.name, () => { + let module: TestingModule; + let rule: SchoolSystemOptionsRule; + + let authorizationHelper: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + SchoolSystemOptionsRule, + { + provide: AuthorizationHelper, + useValue: createMock(), + }, + ], + }).compile(); + + rule = module.get(SchoolSystemOptionsRule); + authorizationHelper = module.get(AuthorizationHelper); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isApplicable', () => { + describe('when the entity is applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + return { + user, + schoolSystemOptions, + }; + }; + + it('should return true', () => { + const { user, schoolSystemOptions } = setup(); + + const result = rule.isApplicable(user, schoolSystemOptions); + + expect(result).toEqual(true); + }); + }); + + describe('when the entity is not applicable', () => { + const setup = () => { + const user: User = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should return false', () => { + const { user } = setup(); + + const result = rule.isApplicable(user, {} as unknown as SchoolSystemOptions); + + expect(result).toEqual(false); + }); + }); + }); + + describe('hasPermission', () => { + describe('when the user accesses a system at his school with the required permissions', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: systemEntity.id, + schoolId: school.id, + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + schoolSystemOptions, + authorizationContext, + }; + }; + + it('should check the permission', () => { + const { user, schoolSystemOptions, authorizationContext } = setup(); + + rule.hasPermission(user, schoolSystemOptions, authorizationContext); + + expect(authorizationHelper.hasAllPermissions).toHaveBeenCalledWith( + user, + authorizationContext.requiredPermissions + ); + }); + + it('should return true', () => { + const { user, schoolSystemOptions, authorizationContext } = setup(); + + const result = rule.hasPermission(user, schoolSystemOptions, authorizationContext); + + expect(result).toEqual(true); + }); + }); + + describe('when the user accesses a system at his school, but does not have the required permissions', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: systemEntity.id, + schoolId: school.id, + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(false); + + return { + user, + schoolSystemOptions, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, schoolSystemOptions, authorizationContext } = setup(); + + const result = rule.hasPermission(user, schoolSystemOptions, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the system is not part of the users school', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: new ObjectId().toHexString(), + schoolId: school.id, + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + schoolSystemOptions, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, schoolSystemOptions, authorizationContext } = setup(); + + const result = rule.hasPermission(user, schoolSystemOptions, authorizationContext); + + expect(result).toEqual(false); + }); + }); + + describe('when the user is not at the school', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [systemEntity], + }); + const user: User = userFactory.buildWithId({ school }); + const authorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]); + + authorizationHelper.hasAllPermissions.mockReturnValueOnce(true); + + return { + user, + schoolSystemOptions, + authorizationContext, + }; + }; + + it('should return false', () => { + const { user, schoolSystemOptions, authorizationContext } = setup(); + + const result = rule.hasPermission(user, schoolSystemOptions, authorizationContext); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.ts b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.ts new file mode 100644 index 00000000000..89a84a5d98f --- /dev/null +++ b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.ts @@ -0,0 +1,28 @@ +import { AnyProvisioningOptions, SchoolSystemOptions } from '@modules/legacy-school'; +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { AuthorizationHelper } from '../service/authorization.helper'; +import { AuthorizationContext, Rule } from '../type'; + +@Injectable() +export class SchoolSystemOptionsRule implements Rule { + constructor(private readonly authorizationHelper: AuthorizationHelper) {} + + public isApplicable(user: User, domainObject: SchoolSystemOptions): boolean { + const isMatched: boolean = domainObject instanceof SchoolSystemOptions; + + return isMatched; + } + + public hasPermission(user: User, domainObject: SchoolSystemOptions, context: AuthorizationContext): boolean { + const hasPermissions: boolean = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); + + const isAtSchool: boolean = user.school.id === domainObject.schoolId; + + const hasSystem: boolean = user.school.systems.getIdentifiers().includes(domainObject.systemId); + + const isAuthorized: boolean = hasPermissions && isAtSchool && hasSystem; + + return isAuthorized; + } +} diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts index 950c881a9a7..1a0914b3c08 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.spec.ts @@ -12,6 +12,7 @@ import { LegacySchoolRule, LessonRule, SchoolExternalToolRule, + SchoolSystemOptionsRule, SubmissionRule, SystemRule, TaskRule, @@ -37,6 +38,7 @@ describe('RuleManager', () => { let userLoginMigrationRule: DeepMocked; let groupRule: DeepMocked; let systemRule: DeepMocked; + let schoolSystemOptionsRule: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -58,6 +60,7 @@ describe('RuleManager', () => { { provide: ContextExternalToolRule, useValue: createMock() }, { provide: UserLoginMigrationRule, useValue: createMock() }, { provide: SystemRule, useValue: createMock() }, + { provide: SchoolSystemOptionsRule, useValue: createMock() }, ], }).compile(); @@ -76,6 +79,7 @@ describe('RuleManager', () => { userLoginMigrationRule = await module.get(UserLoginMigrationRule); groupRule = await module.get(GroupRule); systemRule = await module.get(SystemRule); + schoolSystemOptionsRule = await module.get(SchoolSystemOptionsRule); }); afterEach(() => { @@ -108,6 +112,7 @@ describe('RuleManager', () => { userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); systemRule.isApplicable.mockReturnValueOnce(false); + schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -131,6 +136,7 @@ describe('RuleManager', () => { expect(userLoginMigrationRule.isApplicable).toBeCalled(); expect(groupRule.isApplicable).toBeCalled(); expect(systemRule.isApplicable).toBeCalled(); + expect(schoolSystemOptionsRule.isApplicable).toBeCalled(); }); it('should return CourseRule', () => { @@ -162,6 +168,7 @@ describe('RuleManager', () => { userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); systemRule.isApplicable.mockReturnValueOnce(false); + schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; @@ -193,6 +200,7 @@ describe('RuleManager', () => { userLoginMigrationRule.isApplicable.mockReturnValueOnce(false); groupRule.isApplicable.mockReturnValueOnce(false); systemRule.isApplicable.mockReturnValueOnce(false); + schoolSystemOptionsRule.isApplicable.mockReturnValueOnce(false); return { user, object, context }; }; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index a17bbc3d4b2..fe16abf36cb 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -11,6 +11,7 @@ import { LegacySchoolRule, LessonRule, SchoolExternalToolRule, + SchoolSystemOptionsRule, SubmissionRule, SystemRule, TaskRule, @@ -38,7 +39,8 @@ export class RuleManager { private readonly contextExternalToolRule: ContextExternalToolRule, private readonly userLoginMigrationRule: UserLoginMigrationRule, private readonly groupRule: GroupRule, - private readonly systemRule: SystemRule + private readonly systemRule: SystemRule, + private readonly schoolSystemOptionsRule: SchoolSystemOptionsRule ) { this.rules = [ this.courseRule, @@ -55,6 +57,7 @@ export class RuleManager { this.userLoginMigrationRule, this.groupRule, this.systemRule, + this.schoolSystemOptionsRule, ]; } diff --git a/apps/server/src/modules/board/service/column-board.service.ts b/apps/server/src/modules/board/service/column-board.service.ts index 3a24f58cdf1..737b32797b9 100644 --- a/apps/server/src/modules/board/service/column-board.service.ts +++ b/apps/server/src/modules/board/service/column-board.service.ts @@ -46,7 +46,7 @@ export class ColumnBoardService { return rootBoardDo; } - throw new NotFoundLoggableException(ColumnBoard.name, 'id', rootId); + throw new NotFoundLoggableException(ColumnBoard.name, { id: rootId }); } async getBoardObjectTitlesById(boardIds: EntityId[]): Promise> { diff --git a/apps/server/src/modules/class/repo/classes.repo.ts b/apps/server/src/modules/class/repo/classes.repo.ts index 636fde8abb2..7b3c4784d16 100644 --- a/apps/server/src/modules/class/repo/classes.repo.ts +++ b/apps/server/src/modules/class/repo/classes.repo.ts @@ -42,7 +42,7 @@ export class ClassesRepo { (classId) => !existingEntities.find((entity) => entity.id === classId) ); - throw new NotFoundLoggableException(Class.name, 'id', missingEntityIds.toString()); + throw new NotFoundLoggableException(Class.name, { id: missingEntityIds.toString() }); } existingEntities.forEach((entity) => { diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 82ce4942cc4..5ac511f55b2 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -14,7 +14,7 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { const group: Group | null = await this.groupRepo.findById(id); if (!group) { - throw new NotFoundLoggableException(Group.name, 'id', id); + throw new NotFoundLoggableException(Group.name, { id }); } return group; diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index c7679fbcad2..d1cb9748e7d 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -944,7 +944,7 @@ describe('GroupUc', () => { describe('when the group is not found', () => { const setup = () => { - groupService.findById.mockRejectedValue(new NotFoundLoggableException(Group.name, 'id', 'groupId')); + groupService.findById.mockRejectedValue(new NotFoundLoggableException(Group.name, { id: 'groupId' })); const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 5ac89aff399..13e4741b289 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -6,6 +6,7 @@ import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; +import { SortHelper } from '@shared/common'; import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { LegacySchoolDo, Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; @@ -17,7 +18,6 @@ import { SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes, GroupUser } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; import { GroupService } from '../service'; -import { SortHelper } from '../util'; import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from './dto'; import { GroupUcMapper } from './mapper/group-uc.mapper'; diff --git a/apps/server/src/modules/group/util/index.ts b/apps/server/src/modules/group/util/index.ts deleted file mode 100644 index f8413f66d6e..00000000000 --- a/apps/server/src/modules/group/util/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './sort-helper'; diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts new file mode 100644 index 00000000000..c023b94aa0f --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts @@ -0,0 +1,236 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { + schoolFactory, + schoolSystemOptionsEntityFactory, + systemEntityFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { SchoolSystemOptionsEntity } from '../../entity'; +import { SchulConneXProvisioningOptionsResponse } from '../dto'; + +const baseRouteName = '/schools'; + +describe('School (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('[GET] /schools/:schoolId/systems/:systemId/provisioning-options', () => { + describe('when an admin requests the configured options for a system at their school', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [system], + }); + const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + system, + school, + provisioningOptions: { + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([school, adminAccount, adminUser, system, schoolSystemOptions]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + adminClient, + school, + system, + }; + }; + + it('should return the options', async () => { + const { adminClient, school, system } = await setup(); + + const response = await adminClient.get(`/${school.id}/systems/${system.id}/provisioning-options`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + }); + }); + + describe('when the user is unauthorized', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [system], + }); + const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + system, + school, + provisioningOptions: { + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }, + }); + + await em.persistAndFlush([school, system, schoolSystemOptions]); + em.clear(); + + return { + school, + system, + }; + }; + + it('should return unauthorized', async () => { + const { school, system } = await setup(); + + const response = await testApiClient.get(`/${school.id}/systems/${system.id}/provisioning-options`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + + describe('[POST] /schools/:schoolId/systems/:systemId/provisioning-options', () => { + describe('when an admin requests the configured options for a system at their school', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [system], + }); + const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + system, + school, + provisioningOptions: { + groupProvisioningClassesEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }, + }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([school, adminAccount, adminUser, system, schoolSystemOptions]); + em.clear(); + + const adminClient = await testApiClient.login(adminAccount); + + return { + adminClient, + school, + system, + schoolSystemOptions, + }; + }; + + it('should create the entity', async () => { + const { adminClient, school, system, schoolSystemOptions } = await setup(); + + await adminClient.post(`/${school.id}/systems/${system.id}/provisioning-options`, { + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + + expect(await em.findOne(SchoolSystemOptionsEntity, { id: schoolSystemOptions.id })).toEqual( + expect.objectContaining({ + provisioningOptions: { + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }, + }) + ); + }); + + it('should return the options', async () => { + const { adminClient, school, system } = await setup(); + + const response = await adminClient.post(`/${school.id}/systems/${system.id}/provisioning-options`, { + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + + expect(response.status).toEqual(HttpStatus.CREATED); + expect(response.body).toEqual({ + groupProvisioningClassesEnabled: true, + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + }); + }); + }); + + describe('when the user is unauthorized', () => { + const setup = async () => { + const system: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const school: SchoolEntity = schoolFactory.buildWithId({ + systems: [system], + }); + + await em.persistAndFlush([school, system]); + em.clear(); + + return { + school, + system, + }; + }; + + it('should return unauthorized', async () => { + const { school, system } = await setup(); + + const response = await testApiClient.post(`/${school.id}/systems/${system.id}/provisioning-options`, { + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/controller/dto/index.ts b/apps/server/src/modules/legacy-school/controller/dto/index.ts new file mode 100644 index 00000000000..42e2c0376fb --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/index.ts @@ -0,0 +1,6 @@ +export { SchoolSystemParams } from './request/school-system.params'; +export { ProvisioningOptionsParams } from './request/provisioning-options.params'; +export { SchulConneXProvisioningOptionsParams } from './request/schulconnex-provisioning-options.params'; + +export { AnyProvisioningOptionsResponse } from './response/any-provisioning-options.response'; +export { SchulConneXProvisioningOptionsResponse } from './response/schulconnex-provisioning-options.response'; diff --git a/apps/server/src/modules/legacy-school/controller/dto/request/provisioning-options.params.ts b/apps/server/src/modules/legacy-school/controller/dto/request/provisioning-options.params.ts new file mode 100644 index 00000000000..618320172e7 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/request/provisioning-options.params.ts @@ -0,0 +1,16 @@ +import { IsBoolean, IsOptional } from 'class-validator'; +import { ProvisioningOptionsInterface } from '../../../interface'; + +export class ProvisioningOptionsParams implements ProvisioningOptionsInterface { + @IsOptional() + @IsBoolean() + groupProvisioningClassesEnabled?: boolean; + + @IsOptional() + @IsBoolean() + groupProvisioningCoursesEnabled?: boolean; + + @IsOptional() + @IsBoolean() + groupProvisioningOtherEnabled?: boolean; +} diff --git a/apps/server/src/modules/legacy-school/controller/dto/request/school-system.params.ts b/apps/server/src/modules/legacy-school/controller/dto/request/school-system.params.ts new file mode 100644 index 00000000000..28d355e27e0 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/request/school-system.params.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { IsMongoId } from 'class-validator'; + +export class SchoolSystemParams { + @IsMongoId() + @ApiProperty() + schoolId!: EntityId; + + @IsMongoId() + @ApiProperty() + systemId!: EntityId; +} diff --git a/apps/server/src/modules/legacy-school/controller/dto/request/schulconnex-provisioning-options.params.ts b/apps/server/src/modules/legacy-school/controller/dto/request/schulconnex-provisioning-options.params.ts new file mode 100644 index 00000000000..225a968af27 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/request/schulconnex-provisioning-options.params.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SchulConneXProvisioningOptionsInterface } from '../../../interface'; + +export class SchulConneXProvisioningOptionsParams implements SchulConneXProvisioningOptionsInterface { + @ApiProperty() + groupProvisioningClassesEnabled!: boolean; + + @ApiProperty() + groupProvisioningCoursesEnabled!: boolean; + + @ApiProperty() + groupProvisioningOtherEnabled!: boolean; +} diff --git a/apps/server/src/modules/legacy-school/controller/dto/response/any-provisioning-options.response.ts b/apps/server/src/modules/legacy-school/controller/dto/response/any-provisioning-options.response.ts new file mode 100644 index 00000000000..641e6007ce4 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/response/any-provisioning-options.response.ts @@ -0,0 +1,3 @@ +import { SchulConneXProvisioningOptionsResponse } from './schulconnex-provisioning-options.response'; + +export type AnyProvisioningOptionsResponse = SchulConneXProvisioningOptionsResponse; diff --git a/apps/server/src/modules/legacy-school/controller/dto/response/schulconnex-provisioning-options.response.ts b/apps/server/src/modules/legacy-school/controller/dto/response/schulconnex-provisioning-options.response.ts new file mode 100644 index 00000000000..2596c65f8bd --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/response/schulconnex-provisioning-options.response.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SchulConneXProvisioningOptionsInterface } from '../../../interface'; + +export class SchulConneXProvisioningOptionsResponse implements SchulConneXProvisioningOptionsInterface { + @ApiProperty() + groupProvisioningClassesEnabled: boolean; + + @ApiProperty() + groupProvisioningCoursesEnabled: boolean; + + @ApiProperty() + groupProvisioningOtherEnabled: boolean; + + constructor(props: SchulConneXProvisioningOptionsResponse) { + this.groupProvisioningClassesEnabled = props.groupProvisioningClassesEnabled; + this.groupProvisioningCoursesEnabled = props.groupProvisioningCoursesEnabled; + this.groupProvisioningOtherEnabled = props.groupProvisioningOtherEnabled; + } +} diff --git a/apps/server/src/modules/legacy-school/controller/index.ts b/apps/server/src/modules/legacy-school/controller/index.ts new file mode 100644 index 00000000000..1df39c93979 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/index.ts @@ -0,0 +1 @@ +export { SchoolController } from './school.controller'; diff --git a/apps/server/src/modules/legacy-school/controller/school-system-options.mapper.ts b/apps/server/src/modules/legacy-school/controller/school-system-options.mapper.ts new file mode 100644 index 00000000000..bf07ef3f9e9 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/school-system-options.mapper.ts @@ -0,0 +1,14 @@ +import { AnyProvisioningOptions } from '../domain'; +import { AnyProvisioningOptionsResponse, SchulConneXProvisioningOptionsResponse } from './dto'; + +export class SchoolSystemOptionsMapper { + static mapProvisioningOptionsToResponse(options: AnyProvisioningOptions): AnyProvisioningOptionsResponse { + const mapped: SchulConneXProvisioningOptionsResponse = new SchulConneXProvisioningOptionsResponse({ + groupProvisioningClassesEnabled: options.groupProvisioningClassesEnabled, + groupProvisioningCoursesEnabled: options.groupProvisioningCoursesEnabled, + groupProvisioningOtherEnabled: options.groupProvisioningOtherEnabled, + }); + + return mapped; + } +} diff --git a/apps/server/src/modules/legacy-school/controller/school.controller.ts b/apps/server/src/modules/legacy-school/controller/school.controller.ts new file mode 100644 index 00000000000..1eef6d58229 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/school.controller.ts @@ -0,0 +1,98 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { + ApiBody, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, + ApiUnprocessableEntityResponse, + getSchemaPath, +} from '@nestjs/swagger'; +import { AnyProvisioningOptions } from '../domain'; +import { SchoolSystemOptionsUc } from '../uc'; +import { + AnyProvisioningOptionsResponse, + ProvisioningOptionsParams, + SchoolSystemParams, + SchulConneXProvisioningOptionsParams, + SchulConneXProvisioningOptionsResponse, +} from './dto'; +import { SchoolSystemOptionsMapper } from './school-system-options.mapper'; + +@ApiTags('School') +@Controller('schools') +@Authenticate('jwt') +export class SchoolController { + constructor(private readonly schoolSystemOptionsUc: SchoolSystemOptionsUc) {} + + @Get('/:schoolId/systems/:systemId/provisioning-options') + @ApiOperation({ description: 'Gets all provisioning options for a system at a school' }) + @ApiOkResponse({ + description: 'All provisioning options of the system with their value', + schema: { + oneOf: [ + { + $ref: getSchemaPath(SchulConneXProvisioningOptionsResponse), + }, + ], + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + @ApiUnprocessableEntityResponse() + @ApiNotFoundResponse() + public async getProvisioningOptions( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: SchoolSystemParams + ): Promise { + const options: AnyProvisioningOptions = await this.schoolSystemOptionsUc.getProvisioningOptions( + currentUser.userId, + params.schoolId, + params.systemId + ); + + const mapped: AnyProvisioningOptionsResponse = SchoolSystemOptionsMapper.mapProvisioningOptionsToResponse(options); + + return mapped; + } + + @Post('/:schoolId/systems/:systemId/provisioning-options') + @ApiOperation({ description: 'Sets all provisioning options for a system at a school' }) + @ApiBody({ + type: SchulConneXProvisioningOptionsParams, + }) + @ApiCreatedResponse({ + description: 'All provisioning options of the system with their value', + schema: { + oneOf: [ + { + $ref: getSchemaPath(SchulConneXProvisioningOptionsResponse), + }, + ], + }, + }) + @ApiUnauthorizedResponse() + @ApiForbiddenResponse() + @ApiUnprocessableEntityResponse() + @ApiNotFoundResponse() + public async setProvisioningOptions( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: SchoolSystemParams, + @Body() body: ProvisioningOptionsParams + ): Promise { + const options: AnyProvisioningOptions = await this.schoolSystemOptionsUc.createOrUpdateProvisioningOptions( + currentUser.userId, + params.schoolId, + params.systemId, + body + ); + + const mapped: AnyProvisioningOptionsResponse = SchoolSystemOptionsMapper.mapProvisioningOptionsToResponse(options); + + return mapped; + } +} 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 new file mode 100644 index 00000000000..3676ce72ee2 --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/base-provisioning-options.ts @@ -0,0 +1,15 @@ +import { ProvisioningOptionsInterface } from '../interface'; + +export abstract class BaseProvisioningOptions { + public isApplicable(provisioningOptions: ProvisioningOptionsInterface): provisioningOptions is T { + const expectedKeys: Set = new Set(Object.keys(this)); + const actualKeys: Set = new Set(Object.keys(provisioningOptions)); + + const hasProperties: boolean = + expectedKeys.size === actualKeys.size && [...expectedKeys].every((key: string) => actualKeys.has(key)); + + return hasProperties; + } + + abstract set(props: T): this; +} diff --git a/apps/server/src/modules/legacy-school/domain/index.ts b/apps/server/src/modules/legacy-school/domain/index.ts new file mode 100644 index 00000000000..cc74de0ee4d --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/index.ts @@ -0,0 +1,4 @@ +export { SchulConneXProvisioningOptions } from './schulconnex-provisionin-options.do'; +export { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsProps } from './school-system-options.do'; +export { provisioningStrategyOptions } from './provisioning-strategy-options'; +export { SchoolSystemOptionsBuilder } from './school-system-options.builder'; diff --git a/apps/server/src/modules/legacy-school/domain/provisioning-strategy-options.ts b/apps/server/src/modules/legacy-school/domain/provisioning-strategy-options.ts new file mode 100644 index 00000000000..dc6662aff2d --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/provisioning-strategy-options.ts @@ -0,0 +1,7 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { type AnyProvisioningOptions } from './school-system-options.do'; +import { SchulConneXProvisioningOptions } from './schulconnex-provisionin-options.do'; + +export const provisioningStrategyOptions: Map AnyProvisioningOptions> = new Map([ + [SystemProvisioningStrategy.SANIS, SchulConneXProvisioningOptions], +]); diff --git a/apps/server/src/modules/legacy-school/domain/school-system-options.builder.spec.ts b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.spec.ts new file mode 100644 index 00000000000..6c619059440 --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.spec.ts @@ -0,0 +1,72 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningStrategyInvalidOptionsLoggableException } from '../loggable'; +import { SchoolSystemOptionsBuilder } from './school-system-options.builder'; +import { AnyProvisioningOptions } from './school-system-options.do'; +import { SchulConneXProvisioningOptions } from './schulconnex-provisionin-options.do'; + +describe(SchoolSystemOptionsBuilder.name, () => { + describe('buildProvisioningOptions', () => { + describe('when the provisioning strategy is "SANIS" and the options are valid', () => { + const setup = () => { + const options: ProvisioningOptionsInterface = { + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }; + + return { + options, + }; + }; + + it('should have the correct options instance', () => { + const { options } = setup(); + + const result: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + SystemProvisioningStrategy.SANIS + ).buildProvisioningOptions(options); + + expect(result).toBeInstanceOf(SchulConneXProvisioningOptions); + }); + + it('should return the options', () => { + const { options } = setup(); + + const result: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + SystemProvisioningStrategy.SANIS + ).buildProvisioningOptions(options); + + expect(result).toEqual(options); + }); + }); + + describe('when the provided options do not fit the strategy', () => { + it('should throw an error', () => { + const builder: SchoolSystemOptionsBuilder = new SchoolSystemOptionsBuilder(SystemProvisioningStrategy.SANIS); + + expect(() => + builder.buildProvisioningOptions({ + groupProvisioningClassesEnabled: true, + }) + ).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 new file mode 100644 index 00000000000..f431b27f5ae --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/school-system-options.builder.ts @@ -0,0 +1,28 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningStrategyInvalidOptionsLoggableException } 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 { + const ProvisioningOptionsConstructor: (new () => AnyProvisioningOptions) | undefined = + provisioningStrategyOptions.get(this.provisioningStrategy); + + if (!ProvisioningOptionsConstructor) { + throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); + } + + const createdProvisioningOptions: AnyProvisioningOptions = new ProvisioningOptionsConstructor(); + + if (!createdProvisioningOptions.isApplicable(provisioningOptions)) { + throw new ProvisioningStrategyInvalidOptionsLoggableException(this.provisioningStrategy, provisioningOptions); + } + + createdProvisioningOptions.set(provisioningOptions); + + return createdProvisioningOptions; + } +} diff --git a/apps/server/src/modules/legacy-school/domain/school-system-options.do.ts b/apps/server/src/modules/legacy-school/domain/school-system-options.do.ts new file mode 100644 index 00000000000..0b3c77c0c09 --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/school-system-options.do.ts @@ -0,0 +1,29 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from './schulconnex-provisionin-options.do'; + +export interface SchoolSystemOptionsProps extends AuthorizableObject { + schoolId: EntityId; + + systemId: EntityId; + + provisioningOptions: T; +} + +export class SchoolSystemOptions extends DomainObject< + SchoolSystemOptionsProps +> { + public get schoolId(): EntityId { + return this.props.schoolId; + } + + public get systemId(): EntityId { + return this.props.systemId; + } + + public get provisioningOptions(): T { + return this.props.provisioningOptions; + } +} + +export type AnyProvisioningOptions = SchulConneXProvisioningOptions; 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 new file mode 100644 index 00000000000..7bbcbfc48af --- /dev/null +++ b/apps/server/src/modules/legacy-school/domain/schulconnex-provisionin-options.do.ts @@ -0,0 +1,21 @@ +import { SchulConneXProvisioningOptionsInterface } from '../interface'; +import { BaseProvisioningOptions } from './base-provisioning-options'; + +export class SchulConneXProvisioningOptions + extends BaseProvisioningOptions + implements SchulConneXProvisioningOptionsInterface +{ + groupProvisioningClassesEnabled = true; + + groupProvisioningCoursesEnabled = false; + + groupProvisioningOtherEnabled = false; + + set(props: SchulConneXProvisioningOptionsInterface): this { + this.groupProvisioningClassesEnabled = props.groupProvisioningClassesEnabled; + this.groupProvisioningCoursesEnabled = props.groupProvisioningCoursesEnabled; + this.groupProvisioningOtherEnabled = props.groupProvisioningOtherEnabled; + + return this; + } +} diff --git a/apps/server/src/modules/legacy-school/entity/index.ts b/apps/server/src/modules/legacy-school/entity/index.ts new file mode 100644 index 00000000000..1035e62f455 --- /dev/null +++ b/apps/server/src/modules/legacy-school/entity/index.ts @@ -0,0 +1,2 @@ +export { SchoolSystemOptionsEntityProps, SchoolSystemOptionsEntity } from './school-system-options.entity'; +export { ProvisioningOptionsEntity } from './provisioning-options.entity'; diff --git a/apps/server/src/modules/legacy-school/entity/provisioning-options.entity.ts b/apps/server/src/modules/legacy-school/entity/provisioning-options.entity.ts new file mode 100644 index 00000000000..bb5b847ca4a --- /dev/null +++ b/apps/server/src/modules/legacy-school/entity/provisioning-options.entity.ts @@ -0,0 +1,20 @@ +import { Embeddable, Property } from '@mikro-orm/core'; +import { ProvisioningOptionsInterface } from '../interface'; + +@Embeddable() +export class ProvisioningOptionsEntity implements ProvisioningOptionsInterface { + @Property({ nullable: true }) + groupProvisioningClassesEnabled?: boolean; + + @Property({ nullable: true }) + groupProvisioningCoursesEnabled?: boolean; + + @Property({ nullable: true }) + groupProvisioningOtherEnabled?: boolean; + + constructor(props: ProvisioningOptionsInterface) { + this.groupProvisioningClassesEnabled = props.groupProvisioningClassesEnabled; + this.groupProvisioningCoursesEnabled = props.groupProvisioningCoursesEnabled; + this.groupProvisioningOtherEnabled = props.groupProvisioningOtherEnabled; + } +} diff --git a/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts b/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts new file mode 100644 index 00000000000..9e1be7ef45c --- /dev/null +++ b/apps/server/src/modules/legacy-school/entity/school-system-options.entity.ts @@ -0,0 +1,40 @@ +import { Embedded, Entity, ManyToOne, Unique } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { SchoolEntity } from '@shared/domain/entity/school.entity'; +import { SystemEntity } from '@shared/domain/entity/system.entity'; +import { EntityId } from '@shared/domain/types'; +import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningOptionsEntity } from './provisioning-options.entity'; + +export interface SchoolSystemOptionsEntityProps { + id?: EntityId; + + school: SchoolEntity; + + system: SystemEntity; + + provisioningOptions: ProvisioningOptionsInterface; +} + +@Entity({ tableName: 'school-system-options' }) +@Unique({ properties: ['school', 'system'] }) +export class SchoolSystemOptionsEntity extends BaseEntityWithTimestamps { + @ManyToOne(() => SchoolEntity) + school: SchoolEntity; + + @ManyToOne(() => SystemEntity) + system: SystemEntity; + + @Embedded(() => ProvisioningOptionsEntity) + provisioningOptions: ProvisioningOptionsEntity; + + constructor(props: SchoolSystemOptionsEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.school = props.school; + this.system = props.system; + this.provisioningOptions = new ProvisioningOptionsEntity(props.provisioningOptions); + } +} diff --git a/apps/server/src/modules/legacy-school/index.ts b/apps/server/src/modules/legacy-school/index.ts index 8d7162ddf07..8ee2c7fe67c 100644 --- a/apps/server/src/modules/legacy-school/index.ts +++ b/apps/server/src/modules/legacy-school/index.ts @@ -1,2 +1,9 @@ export * from './legacy-school.module'; export * from './service'; +export { + SchoolSystemOptionsBuilder, + SchoolSystemOptions, + AnyProvisioningOptions, + SchoolSystemOptionsProps, + SchulConneXProvisioningOptions, +} from './domain'; diff --git a/apps/server/src/modules/legacy-school/interface/any-provisioning-options-interface.ts b/apps/server/src/modules/legacy-school/interface/any-provisioning-options-interface.ts new file mode 100644 index 00000000000..7e597029646 --- /dev/null +++ b/apps/server/src/modules/legacy-school/interface/any-provisioning-options-interface.ts @@ -0,0 +1,3 @@ +import { SchulConneXProvisioningOptionsInterface } from './schulconnex-provisioning-options-interface'; + +export type AnyProvisioningOptionsInterface = SchulConneXProvisioningOptionsInterface; diff --git a/apps/server/src/modules/legacy-school/interface/index.ts b/apps/server/src/modules/legacy-school/interface/index.ts new file mode 100644 index 00000000000..dad1ffc7f74 --- /dev/null +++ b/apps/server/src/modules/legacy-school/interface/index.ts @@ -0,0 +1,3 @@ +export { ProvisioningOptionsInterface } from './provisioning-options-interface'; +export { SchulConneXProvisioningOptionsInterface } from './schulconnex-provisioning-options-interface'; +export { AnyProvisioningOptionsInterface } from './any-provisioning-options-interface'; diff --git a/apps/server/src/modules/legacy-school/interface/provisioning-options-interface.ts b/apps/server/src/modules/legacy-school/interface/provisioning-options-interface.ts new file mode 100644 index 00000000000..50c68f7d45e --- /dev/null +++ b/apps/server/src/modules/legacy-school/interface/provisioning-options-interface.ts @@ -0,0 +1,5 @@ +export type ProvisioningOptionsInterface = Partial<{ + groupProvisioningClassesEnabled: boolean; + groupProvisioningCoursesEnabled: boolean; + groupProvisioningOtherEnabled: boolean; +}>; diff --git a/apps/server/src/modules/legacy-school/interface/schulconnex-provisioning-options-interface.ts b/apps/server/src/modules/legacy-school/interface/schulconnex-provisioning-options-interface.ts new file mode 100644 index 00000000000..b11bbed5381 --- /dev/null +++ b/apps/server/src/modules/legacy-school/interface/schulconnex-provisioning-options-interface.ts @@ -0,0 +1,8 @@ +import { ProvisioningOptionsInterface } from './provisioning-options-interface'; + +export type SchulConneXProvisioningOptionsInterface = Required< + Pick< + ProvisioningOptionsInterface, + 'groupProvisioningClassesEnabled' | 'groupProvisioningCoursesEnabled' | 'groupProvisioningOtherEnabled' + > +>; 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 new file mode 100644 index 00000000000..204e40101b4 --- /dev/null +++ b/apps/server/src/modules/legacy-school/legacy-school.api-module.ts @@ -0,0 +1,13 @@ +import { AuthorizationModule } from '@modules/authorization'; +import { SystemModule } from '@modules/system'; +import { Module } from '@nestjs/common'; +import { SchoolController } from './controller'; +import { LegacySchoolModule } from './legacy-school.module'; +import { SchoolSystemOptionsUc } from './uc'; + +@Module({ + imports: [LegacySchoolModule, AuthorizationModule, SystemModule], + controllers: [SchoolController], + providers: [SchoolSystemOptionsUc], +}) +export class LegacySchoolApiModule {} 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 8547c417055..8fc5893bb42 100644 --- a/apps/server/src/modules/legacy-school/legacy-school.module.ts +++ b/apps/server/src/modules/legacy-school/legacy-school.module.ts @@ -1,8 +1,14 @@ import { Module } from '@nestjs/common'; import { FederalStateRepo, LegacySchoolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { SchoolYearRepo } from './repo'; -import { FederalStateService, LegacySchoolService, SchoolValidationService, SchoolYearService } from './service'; +import { SchoolSystemOptionsRepo, SchoolYearRepo } from './repo'; +import { + FederalStateService, + LegacySchoolService, + SchoolSystemOptionsService, + SchoolValidationService, + SchoolYearService, +} from './service'; /** * @deprecated because it uses the deprecated LegacySchoolDo. @@ -17,7 +23,9 @@ import { FederalStateService, LegacySchoolService, SchoolValidationService, Scho FederalStateService, FederalStateRepo, SchoolValidationService, + SchoolSystemOptionsRepo, + SchoolSystemOptionsService, ], - exports: [LegacySchoolService, SchoolYearService, FederalStateService], + 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 45e9f0e09c4..5e5deb07997 100644 --- a/apps/server/src/modules/legacy-school/loggable/index.ts +++ b/apps/server/src/modules/legacy-school/loggable/index.ts @@ -1 +1,4 @@ -export * from './school-number-duplicate.loggable-exception'; +export { SchoolNumberDuplicateLoggableException } from './school-number-duplicate.loggable-exception'; +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'; diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.spec.ts new file mode 100644 index 00000000000..0f0a18cd027 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.spec.ts @@ -0,0 +1,42 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain/types'; +import { SchulConneXProvisioningOptions } from '../domain'; +import { ProvisioningOptionsInvalidTypeLoggableException } from './provisioning-options-invalid-type.loggable-exception'; + +describe(ProvisioningOptionsInvalidTypeLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + + const exception = new ProvisioningOptionsInvalidTypeLoggableException( + SchulConneXProvisioningOptions, + schoolId, + systemId + ); + + return { + exception, + schoolId, + systemId, + }; + }; + + it('should log the correct message', () => { + const { exception, schoolId, systemId } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'PROVISIONING_OPTIONS_INVALID_TYPE', + message: expect.any(String), + stack: expect.any(String), + data: { + expectedType: SchulConneXProvisioningOptions.name, + schoolId, + systemId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.ts new file mode 100644 index 00000000000..82ac278ce1f --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-options-invalid-type.loggable-exception.ts @@ -0,0 +1,27 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { type AnyProvisioningOptions } from '../domain'; + +export class ProvisioningOptionsInvalidTypeLoggableException extends UnprocessableEntityException implements Loggable { + constructor( + private readonly expectedType: new () => AnyProvisioningOptions, + private readonly schoolId: EntityId, + private readonly systemId: EntityId + ) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PROVISIONING_OPTIONS_INVALID_TYPE', + message: 'The provisioning options are not of the expected type.', + stack: this.stack, + data: { + expectedType: this.expectedType.name, + schoolId: this.schoolId, + systemId: this.systemId, + }, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.spec.ts new file mode 100644 index 00000000000..69b310e65f3 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.spec.ts @@ -0,0 +1,39 @@ +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningStrategyInvalidOptionsLoggableException } from './provisioning-strategy-invalid-options.loggable-exception'; + +describe(ProvisioningStrategyInvalidOptionsLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const provisioningOptions: ProvisioningOptionsInterface = { + groupProvisioningOtherEnabled: true, + }; + + const exception = new ProvisioningStrategyInvalidOptionsLoggableException( + SystemProvisioningStrategy.SANIS, + provisioningOptions + ); + + return { + exception, + provisioningOptions, + }; + }; + + it('should log the correct message', () => { + const { exception, provisioningOptions } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'PROVISIONING_STRATEGY_INVALID_OPTIONS', + message: expect.any(String), + stack: expect.any(String), + data: { + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningOptions, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.ts new file mode 100644 index 00000000000..375127bbd18 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-invalid-options.loggable-exception.ts @@ -0,0 +1,28 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { ProvisioningOptionsInterface } from '../interface'; + +export class ProvisioningStrategyInvalidOptionsLoggableException + extends UnprocessableEntityException + implements Loggable +{ + constructor( + private readonly provisioningStrategy: SystemProvisioningStrategy, + private readonly provisioningOptions: ProvisioningOptionsInterface + ) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PROVISIONING_STRATEGY_INVALID_OPTIONS', + message: 'The provisioning options are invalid for this strategy type.', + stack: this.stack, + data: { + provisioningStrategy: this.provisioningStrategy, + provisioningOptions: this.provisioningOptions, + }, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.spec.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.spec.ts new file mode 100644 index 00000000000..16d9ad7ef2a --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.spec.ts @@ -0,0 +1,33 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain/types'; +import { ProvisioningStrategyMissingLoggableException } from './provisioning-strategy-missing.loggable-exception'; + +describe(ProvisioningStrategyMissingLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const systemId: EntityId = new ObjectId().toHexString(); + + const exception = new ProvisioningStrategyMissingLoggableException(systemId); + + return { + exception, + systemId, + }; + }; + + it('should log the correct message', () => { + const { exception, systemId } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'PROVISIONING_STRATEGY_MISSING', + message: expect.any(String), + stack: expect.any(String), + data: { + systemId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.ts b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.ts new file mode 100644 index 00000000000..df200ef9289 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/provisioning-strategy-missing.loggable-exception.ts @@ -0,0 +1,20 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class ProvisioningStrategyMissingLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly systemId: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'PROVISIONING_STRATEGY_MISSING', + message: 'Systems without a provisioning strategy cannot have provisioning options.', + stack: this.stack, + data: { + systemId: this.systemId, + }, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/repo/index.ts b/apps/server/src/modules/legacy-school/repo/index.ts index cea3b664aa9..2debfdb6ba8 100644 --- a/apps/server/src/modules/legacy-school/repo/index.ts +++ b/apps/server/src/modules/legacy-school/repo/index.ts @@ -1 +1,2 @@ export * from './schoolyear.repo'; +export { SchoolSystemOptionsRepo } from './school-system-options.repo'; diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts b/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts new file mode 100644 index 00000000000..44ca21bb878 --- /dev/null +++ b/apps/server/src/modules/legacy-school/repo/school-system-options-repo.mapper.ts @@ -0,0 +1,36 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsProps } from '../domain'; +import { SchoolSystemOptionsEntity, SchoolSystemOptionsEntityProps } from '../entity'; + +export class SchoolSystemOptionsRepoMapper { + static mapDomainObjectToEntityProperties( + schoolSystemOptions: SchoolSystemOptions, + em: EntityManager + ): SchoolSystemOptionsEntityProps { + const props: SchoolSystemOptionsProps = schoolSystemOptions.getProps(); + + const mapped: SchoolSystemOptionsEntityProps = { + id: props.id, + school: em.getReference(SchoolEntity, props.schoolId), + system: em.getReference(SystemEntity, props.systemId), + provisioningOptions: { ...props.provisioningOptions }, + }; + + return mapped; + } + + static mapEntityToDomainObjectProperties( + entity: SchoolSystemOptionsEntity, + provisioningOptions: AnyProvisioningOptions + ): SchoolSystemOptionsProps { + const mapped: SchoolSystemOptionsProps = { + id: entity.id, + schoolId: entity.school.id, + systemId: entity.system.id, + provisioningOptions, + }; + + return mapped; + } +} diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts new file mode 100644 index 00000000000..602448b022d --- /dev/null +++ b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts @@ -0,0 +1,271 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { + schoolFactory, + schoolSystemOptionsEntityFactory, + schoolSystemOptionsFactory, + systemEntityFactory, +} from '@shared/testing'; +import { SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; +import { SchoolSystemOptionsEntity } from '../entity'; +import { ProvisioningStrategyMissingLoggableException } from '../loggable'; +import { SchoolSystemOptionsRepo } from './school-system-options.repo'; + +describe(SchoolSystemOptionsRepo.name, () => { + let module: TestingModule; + let repo: SchoolSystemOptionsRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [SchoolSystemOptionsRepo], + }).compile(); + + repo = module.get(SchoolSystemOptionsRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findBySchoolIdAndSystemId', () => { + describe('when an entity exists', () => { + const setup = async () => { + const schoolSystemOptionsEntity: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + provisioningOptions: { + groupProvisioningOtherEnabled: true, + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + }, + }); + + await em.persistAndFlush(schoolSystemOptionsEntity); + em.clear(); + + return { + schoolSystemOptionsEntity, + }; + }; + + it('should return the options object', async () => { + const { schoolSystemOptionsEntity } = await setup(); + + const result: SchoolSystemOptions | null = await repo.findBySchoolIdAndSystemId( + schoolSystemOptionsEntity.school.id, + schoolSystemOptionsEntity.system.id + ); + + expect(result?.getProps()).toEqual({ + id: schoolSystemOptionsEntity.id, + schoolId: schoolSystemOptionsEntity.school.id, + systemId: schoolSystemOptionsEntity.system.id, + provisioningOptions: { + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningClassesEnabled: true, + }, + }); + }); + }); + + describe('when the entity does not exists', () => { + it('should return null', async () => { + const result: SchoolSystemOptions | null = await repo.findBySchoolIdAndSystemId( + new ObjectId().toHexString(), + new ObjectId().toHexString() + ); + + expect(result).toBeNull(); + }); + }); + + describe('when the linked system has no provisioning strategy', () => { + const setup = async () => { + const schoolSystemOptionsEntity: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + system: systemEntityFactory.buildWithId({ provisioningStrategy: undefined }), + provisioningOptions: { + groupProvisioningOtherEnabled: true, + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + }, + }); + + await em.persistAndFlush(schoolSystemOptionsEntity); + em.clear(); + + return { + schoolSystemOptionsEntity, + }; + }; + + it('should throw an error', async () => { + const { schoolSystemOptionsEntity } = await setup(); + + await expect( + repo.findBySchoolIdAndSystemId(schoolSystemOptionsEntity.school.id, schoolSystemOptionsEntity.system.id) + ).rejects.toThrow(ProvisioningStrategyMissingLoggableException); + }); + }); + }); + + describe('save', () => { + describe('when a new object is provided', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: systemEntity.id, + schoolId: schoolEntity.id, + }); + + await em.persistAndFlush([schoolEntity]); + em.clear(); + + return { + schoolSystemOptions, + }; + }; + + it('should create a new entity', async () => { + const { schoolSystemOptions } = await setup(); + + await repo.save(schoolSystemOptions); + + await expect(em.findOneOrFail(SchoolSystemOptionsEntity, schoolSystemOptions.id)).resolves.toBeDefined(); + }); + + it('should return the object', async () => { + const { schoolSystemOptions } = await setup(); + + const result: SchoolSystemOptions | null = await repo.save(schoolSystemOptions); + + expect(result?.getProps()).toEqual({ + id: schoolSystemOptions.id, + schoolId: schoolSystemOptions.schoolId, + systemId: schoolSystemOptions.systemId, + provisioningOptions: { + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }, + }); + }); + }); + + describe('when an entity exists', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: SystemProvisioningStrategy.SANIS, + }); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + const schoolSystemOptionsEntity: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ + school: schoolEntity, + system: systemEntity, + provisioningOptions: { + groupProvisioningOtherEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningClassesEnabled: false, + }, + }); + + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + id: schoolSystemOptionsEntity.id, + systemId: systemEntity.id, + schoolId: schoolEntity.id, + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningClassesEnabled: true, + }), + }); + + await em.persistAndFlush([schoolEntity]); + em.clear(); + + return { + schoolSystemOptions, + }; + }; + + it('should update the entity', async () => { + const { schoolSystemOptions } = await setup(); + + await repo.save(schoolSystemOptions); + + await expect(em.findOneOrFail(SchoolSystemOptionsEntity, schoolSystemOptions.id)).resolves.toEqual( + expect.objectContaining>({ + provisioningOptions: { + groupProvisioningOtherEnabled: true, + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + }, + }) + ); + }); + + it('should return the object', async () => { + const { schoolSystemOptions } = await setup(); + + const result: SchoolSystemOptions | null = await repo.save(schoolSystemOptions); + + expect(result?.getProps()).toEqual({ + id: schoolSystemOptions.id, + schoolId: schoolSystemOptions.schoolId, + systemId: schoolSystemOptions.systemId, + provisioningOptions: { + groupProvisioningOtherEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningClassesEnabled: true, + }, + }); + }); + }); + + describe('when the provided system has no provisioning strategy', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ + provisioningStrategy: undefined, + }); + const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: systemEntity.id, + schoolId: schoolEntity.id, + }); + + await em.persistAndFlush([schoolEntity]); + em.clear(); + + return { + schoolSystemOptions, + }; + }; + + it('should not create a new entity', async () => { + const { schoolSystemOptions } = await setup(); + + await expect(repo.save(schoolSystemOptions)).rejects.toThrow(); + + await expect(em.findOne(SchoolSystemOptionsEntity, schoolSystemOptions.id)).resolves.toBeNull(); + }); + + it('should throw an error', async () => { + const { schoolSystemOptions } = await setup(); + + await expect(repo.save(schoolSystemOptions)).rejects.toThrow(ProvisioningStrategyMissingLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts new file mode 100644 index 00000000000..16c96463c74 --- /dev/null +++ b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.ts @@ -0,0 +1,94 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { SystemEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { EntityId } from '@shared/domain/types'; +import { + AnyProvisioningOptions, + SchoolSystemOptions, + SchoolSystemOptionsBuilder, + SchoolSystemOptionsProps, +} from '../domain'; +import { SchoolSystemOptionsEntity, SchoolSystemOptionsEntityProps } from '../entity'; +import { ProvisioningStrategyMissingLoggableException } from '../loggable'; +import { SchoolSystemOptionsRepoMapper } from './school-system-options-repo.mapper'; + +@Injectable() +export class SchoolSystemOptionsRepo { + constructor(private readonly em: EntityManager) {} + + public async findBySchoolIdAndSystemId(schoolId: EntityId, systemId: EntityId): Promise { + const entity: SchoolSystemOptionsEntity | null = await this.em.findOne( + SchoolSystemOptionsEntity, + { + school: schoolId, + system: systemId, + }, + { populate: ['system.provisioningStrategy'] } + ); + + if (!entity) { + return null; + } + + if (!entity.system.provisioningStrategy) { + throw new ProvisioningStrategyMissingLoggableException(entity.system.id); + } + + const domainObject: SchoolSystemOptions = this.buildDomainObject(entity, entity.system.provisioningStrategy); + + return domainObject; + } + + public async save(domainObject: SchoolSystemOptions): Promise { + const entityProps: SchoolSystemOptionsEntityProps = SchoolSystemOptionsRepoMapper.mapDomainObjectToEntityProperties( + domainObject, + this.em + ); + + const newEntity: SchoolSystemOptionsEntity = new SchoolSystemOptionsEntity(entityProps); + + const existingEntity: SchoolSystemOptionsEntity | null = await this.em.findOne(SchoolSystemOptionsEntity, { + id: domainObject.id, + }); + + const system: SystemEntity | null = await this.em.findOne(SystemEntity, { + id: domainObject.systemId, + }); + + if (!system?.provisioningStrategy) { + throw new ProvisioningStrategyMissingLoggableException(domainObject.systemId); + } + + let savedEntity: SchoolSystemOptionsEntity; + if (existingEntity) { + savedEntity = this.em.assign(existingEntity, newEntity); + } else { + this.em.persist(newEntity); + + savedEntity = newEntity; + } + + await this.em.flush(); + + const savedDomainObject: SchoolSystemOptions = this.buildDomainObject(savedEntity, system.provisioningStrategy); + + return savedDomainObject; + } + + private buildDomainObject( + entity: SchoolSystemOptionsEntity, + provisioningStrategy: SystemProvisioningStrategy + ): SchoolSystemOptions { + const provisioningOptions: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + provisioningStrategy + ).buildProvisioningOptions(entity.provisioningOptions); + + const props: SchoolSystemOptionsProps = + SchoolSystemOptionsRepoMapper.mapEntityToDomainObjectProperties(entity, provisioningOptions); + + const domainObject: SchoolSystemOptions = new SchoolSystemOptions(props); + + return domainObject; + } +} diff --git a/apps/server/src/modules/legacy-school/service/index.ts b/apps/server/src/modules/legacy-school/service/index.ts index 09f01c98215..65307ab17f0 100644 --- a/apps/server/src/modules/legacy-school/service/index.ts +++ b/apps/server/src/modules/legacy-school/service/index.ts @@ -2,3 +2,4 @@ export * from './legacy-school.service'; export * from './school-year.service'; export * from './federal-state.service'; export * from './validation'; +export { SchoolSystemOptionsService } from './school-system-options.service'; diff --git a/apps/server/src/modules/legacy-school/service/school-system-options.service.spec.ts b/apps/server/src/modules/legacy-school/service/school-system-options.service.spec.ts new file mode 100644 index 00000000000..8bb85d487ab --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/school-system-options.service.spec.ts @@ -0,0 +1,182 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { schoolSystemOptionsFactory } from '@shared/testing'; +import { SchoolSystemOptions, SchulConneXProvisioningOptions } from '../domain'; +import { SchulConneXProvisioningOptionsInterface } from '../interface'; +import { ProvisioningOptionsInvalidTypeLoggableException } from '../loggable'; +import { SchoolSystemOptionsRepo } from '../repo'; +import { SchoolSystemOptionsService } from './school-system-options.service'; + +describe(SchoolSystemOptionsService.name, () => { + let module: TestingModule; + let service: SchoolSystemOptionsService; + + let schoolSystemOptionsRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchoolSystemOptionsService, + { + provide: SchoolSystemOptionsRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchoolSystemOptionsService); + schoolSystemOptionsRepo = module.get(SchoolSystemOptionsRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findBySchoolIdAndSystemId', () => { + describe('when there are options', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + schoolSystemOptionsRepo.findBySchoolIdAndSystemId.mockResolvedValue(schoolSystemOptions); + + return { + schoolSystemOptions, + }; + }; + + it('should return the options', async () => { + const { schoolSystemOptions } = setup(); + + const result: SchoolSystemOptions | null = await service.findBySchoolIdAndSystemId( + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId + ); + + expect(result).toEqual(schoolSystemOptions); + }); + }); + + describe('when there are no options', () => { + const setup = () => { + schoolSystemOptionsRepo.findBySchoolIdAndSystemId.mockResolvedValue(null); + }; + + it('should return null', async () => { + setup(); + + const result: SchoolSystemOptions | null = await service.findBySchoolIdAndSystemId( + new ObjectId().toHexString(), + new ObjectId().toHexString() + ); + + expect(result).toBeNull(); + }); + }); + }); + + describe('getProvisioningOptions', () => { + describe('when there are options', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + schoolSystemOptionsRepo.findBySchoolIdAndSystemId.mockResolvedValue(schoolSystemOptions); + + return { + schoolSystemOptions, + }; + }; + + it('should return the options', async () => { + const { schoolSystemOptions } = setup(); + + const result: SchulConneXProvisioningOptions = await service.getProvisioningOptions( + SchulConneXProvisioningOptions, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId + ); + + expect(result).toEqual(schoolSystemOptions.provisioningOptions); + }); + }); + + describe('when there are no options', () => { + it('should return the default options', async () => { + const result: SchulConneXProvisioningOptions = await service.getProvisioningOptions( + SchulConneXProvisioningOptions, + new ObjectId().toHexString(), + new ObjectId().toHexString() + ); + + expect(result).toEqual({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }); + }); + }); + + describe('when the options are not of the requested type', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + schoolSystemOptionsRepo.findBySchoolIdAndSystemId.mockResolvedValue( + new SchoolSystemOptions({ + ...schoolSystemOptions.getProps(), + provisioningOptions: {} as unknown as SchulConneXProvisioningOptions, + }) + ); + + return { + schoolSystemOptions, + }; + }; + + it('should throw an error', async () => { + const { schoolSystemOptions } = setup(); + + await expect( + service.getProvisioningOptions( + SchulConneXProvisioningOptions, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId + ) + ).rejects.toThrow(ProvisioningOptionsInvalidTypeLoggableException); + }); + }); + }); + + describe('save', () => { + describe('when saving options', () => { + const setup = () => { + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + schoolSystemOptionsRepo.save.mockResolvedValue(schoolSystemOptions); + + return { + schoolSystemOptions, + }; + }; + + it('should save the options', async () => { + const { schoolSystemOptions } = setup(); + + await service.save(schoolSystemOptions); + + expect(schoolSystemOptionsRepo.save).toHaveBeenCalledWith(schoolSystemOptions); + }); + + it('should return the options', async () => { + const { schoolSystemOptions } = setup(); + + const result: SchoolSystemOptions = await service.save(schoolSystemOptions); + + expect(result).toEqual(schoolSystemOptions); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/service/school-system-options.service.ts b/apps/server/src/modules/legacy-school/service/school-system-options.service.ts new file mode 100644 index 00000000000..0f9ed3c926d --- /dev/null +++ b/apps/server/src/modules/legacy-school/service/school-system-options.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions, SchoolSystemOptions } from '../domain'; +import { ProvisioningOptionsInvalidTypeLoggableException } from '../loggable'; +import { SchoolSystemOptionsRepo } from '../repo'; + +@Injectable() +export class SchoolSystemOptionsService { + constructor(private readonly schoolSystemOptionsRepo: SchoolSystemOptionsRepo) {} + + public async findBySchoolIdAndSystemId(schoolId: EntityId, systemId: EntityId): Promise { + const schoolSystemOptions: SchoolSystemOptions | null = + await this.schoolSystemOptionsRepo.findBySchoolIdAndSystemId(schoolId, systemId); + + return schoolSystemOptions; + } + + public async getProvisioningOptions( + ProvisioningOptionsConstructor: new () => T, + schoolId: EntityId, + systemId: EntityId + ): Promise { + const schoolSystemOptions: SchoolSystemOptions | null = + await this.schoolSystemOptionsRepo.findBySchoolIdAndSystemId(schoolId, systemId); + + let options: T; + if (schoolSystemOptions) { + if (!(schoolSystemOptions.provisioningOptions instanceof ProvisioningOptionsConstructor)) { + throw new ProvisioningOptionsInvalidTypeLoggableException(ProvisioningOptionsConstructor, schoolId, systemId); + } + + options = schoolSystemOptions.provisioningOptions; + } else { + const defaultOptions: T = new ProvisioningOptionsConstructor(); + + options = defaultOptions; + } + + return options; + } + + public async save(schoolSystemOptions: SchoolSystemOptions): Promise { + const savedSchoolSystemOptions: SchoolSystemOptions = await this.schoolSystemOptionsRepo.save(schoolSystemOptions); + + return savedSchoolSystemOptions; + } +} diff --git a/apps/server/src/modules/legacy-school/uc/index.ts b/apps/server/src/modules/legacy-school/uc/index.ts new file mode 100644 index 00000000000..d83e4aff3bb --- /dev/null +++ b/apps/server/src/modules/legacy-school/uc/index.ts @@ -0,0 +1 @@ +export { SchoolSystemOptionsUc } from './school-system-options.uc'; 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 new file mode 100644 index 00000000000..21312bc9988 --- /dev/null +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.spec.ts @@ -0,0 +1,342 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { System, SystemService } from '@modules/system'; +import { Test, TestingModule } from '@nestjs/testing'; +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 { ProvisioningStrategyMissingLoggableException } from '../loggable'; +import { SchoolSystemOptionsService } from '../service'; +import { SchoolSystemOptionsUc } from './school-system-options.uc'; + +describe(SchoolSystemOptionsUc.name, () => { + let module: TestingModule; + let uc: SchoolSystemOptionsUc; + + let authorizationService: DeepMocked; + let systemService: DeepMocked; + let schoolSystemOptionsService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + SchoolSystemOptionsUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SystemService, + useValue: createMock(), + }, + { + provide: SchoolSystemOptionsService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(SchoolSystemOptionsUc); + authorizationService = module.get(AuthorizationService); + systemService = module.get(SystemService); + schoolSystemOptionsService = module.get(SchoolSystemOptionsService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getProvisioningOptions', () => { + describe('when options exist', () => { + const setup = () => { + const user = userFactory.asAdmin().buildWithId(); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); + + schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(schoolSystemOptions); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + schoolSystemOptions, + }; + }; + + it('should check the permissions', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.getProvisioningOptions(user.id, schoolSystemOptions.schoolId, schoolSystemOptions.systemId); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + schoolSystemOptions, + AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]) + ); + }); + + it('should return the options', async () => { + const { user, schoolSystemOptions } = setup(); + + const result: AnyProvisioningOptions = await uc.getProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId + ); + + expect(result).toEqual(schoolSystemOptions.provisioningOptions); + }); + }); + + describe('when no options exist', () => { + const setup = () => { + const user = userFactory.asAdmin().buildWithId(); + + schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(null); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect( + uc.getProvisioningOptions(user.id, new ObjectId().toHexString(), new ObjectId().toHexString()) + ).rejects.toThrow(NotFoundLoggableException); + }); + }); + }); + + describe('createOrUpdateProvisioningOptions', () => { + describe('when saving new options to a system at the school', () => { + const setup = () => { + const user = userFactory.asAdmin().buildWithId(); + const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: system.id, + }); + + systemService.findById.mockResolvedValueOnce(system); + schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(null); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolSystemOptionsService.save.mockResolvedValueOnce(schoolSystemOptions); + + return { + user, + schoolSystemOptions, + }; + }; + + it('should check the permissions', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), id: expect.any(String) }), + AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) + ); + }); + + it('should save the options', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(schoolSystemOptionsService.save).toHaveBeenCalledWith( + new SchoolSystemOptions({ ...schoolSystemOptions.getProps(), id: expect.any(String) }) + ); + }); + + it('should return the options', async () => { + const { user, schoolSystemOptions } = setup(); + + const result: AnyProvisioningOptions = await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(result).toEqual(schoolSystemOptions.provisioningOptions); + }); + }); + + describe('when saving existing options to a system at the school', () => { + const setup = () => { + const user = userFactory.asAdmin().buildWithId(); + const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: system.id, + }); + + systemService.findById.mockResolvedValueOnce(system); + schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(schoolSystemOptions); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolSystemOptionsService.save.mockResolvedValueOnce(schoolSystemOptions); + + return { + user, + schoolSystemOptions, + }; + }; + + it('should check the permissions', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + schoolSystemOptions, + AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) + ); + }); + + it('should save the options', async () => { + const { user, schoolSystemOptions } = setup(); + + await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(schoolSystemOptionsService.save).toHaveBeenCalledWith(schoolSystemOptions); + }); + + it('should return the options', async () => { + const { user, schoolSystemOptions } = setup(); + + const result: AnyProvisioningOptions = await uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ); + + expect(result).toEqual(schoolSystemOptions.provisioningOptions); + }); + }); + + describe('when the requested system does not exist', () => { + const setup = () => { + systemService.findById.mockResolvedValueOnce(null); + }; + + it('should throw an error', async () => { + setup(); + + await expect( + uc.createOrUpdateProvisioningOptions( + new ObjectId().toHexString(), + new ObjectId().toHexString(), + new ObjectId().toHexString(), + {} + ) + ).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the requested system does not have a provisioning strategy', () => { + const setup = () => { + const system: System = systemFactory.build({ provisioningStrategy: undefined }); + + systemService.findById.mockResolvedValueOnce(system); + }; + + it('should throw an error', async () => { + setup(); + + await expect( + uc.createOrUpdateProvisioningOptions( + new ObjectId().toHexString(), + new ObjectId().toHexString(), + new ObjectId().toHexString(), + {} + ) + ).rejects.toThrow(ProvisioningStrategyMissingLoggableException); + }); + }); + + describe('when the authorization fails', () => { + const setup = () => { + const user = userFactory.asStudent().buildWithId(); + const system: System = systemFactory.build({ provisioningStrategy: SystemProvisioningStrategy.SANIS }); + const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ + systemId: system.id, + }); + + const error = new Error('Unauthorized'); + + systemService.findById.mockResolvedValueOnce(system); + schoolSystemOptionsService.findBySchoolIdAndSystemId.mockResolvedValueOnce(schoolSystemOptions); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.checkPermission.mockImplementationOnce(() => { + throw error; + }); + + return { + user, + schoolSystemOptions, + error, + }; + }; + + it('should not save', async () => { + const { user, schoolSystemOptions } = setup(); + + await expect( + uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ) + ).rejects.toThrow(); + + expect(schoolSystemOptionsService.save).not.toHaveBeenCalled(); + }); + + it('should throw an error', async () => { + const { user, schoolSystemOptions, error } = setup(); + + await expect( + uc.createOrUpdateProvisioningOptions( + user.id, + schoolSystemOptions.schoolId, + schoolSystemOptions.systemId, + schoolSystemOptions.provisioningOptions + ) + ).rejects.toThrow(error); + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..ad4e0d1da59 --- /dev/null +++ b/apps/server/src/modules/legacy-school/uc/school-system-options.uc.ts @@ -0,0 +1,86 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { System, SystemService } from '@modules/system'; +import { Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { AnyProvisioningOptions, SchoolSystemOptions, SchoolSystemOptionsBuilder } from '../domain'; +import { ProvisioningOptionsInterface } from '../interface'; +import { ProvisioningStrategyMissingLoggableException } from '../loggable'; +import { SchoolSystemOptionsService } from '../service'; + +@Injectable() +export class SchoolSystemOptionsUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly systemService: SystemService, + private readonly schoolSystemOptionsService: SchoolSystemOptionsService + ) {} + + public async getProvisioningOptions( + userId: EntityId, + schoolId: EntityId, + systemId: EntityId + ): Promise { + const schoolSystemOptions: SchoolSystemOptions | null = + await this.schoolSystemOptionsService.findBySchoolIdAndSystemId(schoolId, systemId); + + if (!schoolSystemOptions) { + throw new NotFoundLoggableException(SchoolSystemOptions.name, { schoolId, systemId }); + } + + const user = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + schoolSystemOptions, + AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]) + ); + + return schoolSystemOptions.provisioningOptions; + } + + public async createOrUpdateProvisioningOptions( + userId: EntityId, + schoolId: EntityId, + systemId: EntityId, + requestedProvisioningOptions: ProvisioningOptionsInterface + ): Promise { + const system: System | null = await this.systemService.findById(systemId); + + if (!system) { + throw new NotFoundLoggableException(System.name, { id: systemId }); + } + + if (!system.provisioningStrategy) { + throw new ProvisioningStrategyMissingLoggableException(systemId); + } + + const provisioningOptions: AnyProvisioningOptions = new SchoolSystemOptionsBuilder( + system.provisioningStrategy + ).buildProvisioningOptions(requestedProvisioningOptions); + + const existingSchoolSystemOptions: SchoolSystemOptions | null = + await this.schoolSystemOptionsService.findBySchoolIdAndSystemId(schoolId, systemId); + + const schoolSystemOptions: SchoolSystemOptions = new SchoolSystemOptions({ + id: existingSchoolSystemOptions?.id ?? new ObjectId().toHexString(), + systemId, + schoolId, + provisioningOptions, + }); + + const user = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + schoolSystemOptions, + AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_EDIT]) + ); + + const savedSchoolSystemOptions: SchoolSystemOptions = await this.schoolSystemOptionsService.save( + schoolSystemOptions + ); + + return savedSchoolSystemOptions.provisioningOptions; + } +} diff --git a/apps/server/src/modules/provisioning/config/index.ts b/apps/server/src/modules/provisioning/config/index.ts new file mode 100644 index 00000000000..dbbb1de579b --- /dev/null +++ b/apps/server/src/modules/provisioning/config/index.ts @@ -0,0 +1 @@ +export { ProvisioningFeatures, ProvisioningConfiguration, IProvisioningFeatures } from './provisioning-config'; diff --git a/apps/server/src/modules/provisioning/config/provisioning-config.ts b/apps/server/src/modules/provisioning/config/provisioning-config.ts new file mode 100644 index 00000000000..4f6d83fc19d --- /dev/null +++ b/apps/server/src/modules/provisioning/config/provisioning-config.ts @@ -0,0 +1,15 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; + +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/provisioning-config.module.ts b/apps/server/src/modules/provisioning/provisioning-config.module.ts new file mode 100644 index 00000000000..2e1aad944e9 --- /dev/null +++ b/apps/server/src/modules/provisioning/provisioning-config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ProvisioningConfiguration, ProvisioningFeatures } from './config'; + +@Module({ + providers: [ + { + provide: ProvisioningFeatures, + useValue: ProvisioningConfiguration.provisioningFeatures, + }, + ], + exports: [ProvisioningFeatures], +}) +export class ProvisioningConfigModule {} diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index 185516e3f38..3ae6e61ba50 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -1,19 +1,25 @@ -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; import { AccountModule } from '@modules/account/account.module'; -import { RoleModule } from '@modules/role'; +import { GroupModule } from '@modules/group'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { RoleModule } from '@modules/role'; import { SystemModule } from '@modules/system/system.module'; import { UserModule } from '@modules/user'; -import { GroupModule } from '@modules/group'; +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { ProvisioningConfigModule } from './provisioning-config.module'; import { ProvisioningService } from './service/provisioning.service'; -import { IservProvisioningStrategy, OidcMockProvisioningStrategy, SanisProvisioningStrategy } from './strategy'; +import { + IservProvisioningStrategy, + OidcMockProvisioningStrategy, + SanisProvisioningStrategy, + SanisResponseMapper, +} from './strategy'; import { OidcProvisioningService } from './strategy/oidc/service/oidc-provisioning.service'; -import { SanisResponseMapper } from './strategy/sanis/sanis-response.mapper'; @Module({ imports: [ + ProvisioningConfigModule, AccountModule, LegacySchoolModule, UserModule, 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 2d369fa40e9..80c078b52dd 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 @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { ObjectId } from '@mikro-orm/mongodb'; import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; @@ -11,7 +11,9 @@ import { legacySchoolDoFactory, userDoFactory, } from '@shared/testing'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { + ExternalGroupDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, @@ -38,6 +40,7 @@ describe('OidcStrategy', () => { let strategy: TestOidcStrategy; let oidcProvisioningService: DeepMocked; + let provisioningFeatures: IProvisioningFeatures; beforeAll(async () => { module = await Test.createTestingModule({ @@ -47,11 +50,23 @@ describe('OidcStrategy', () => { provide: OidcProvisioningService, useValue: createMock(), }, + { + provide: ProvisioningFeatures, + useValue: {}, + }, ], }).compile(); strategy = module.get(TestOidcStrategy); oidcProvisioningService = module.get(OidcProvisioningService); + provisioningFeatures = module.get(ProvisioningFeatures); + }); + + beforeEach(() => { + Object.assign>(provisioningFeatures, { + schulconnexGroupProvisioningEnabled: false, + provisioningOptionsEnabled: false, + }); }); afterAll(async () => { @@ -178,26 +193,29 @@ describe('OidcStrategy', () => { describe('when group data is provided and the feature is enabled', () => { const setup = () => { - Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', true); + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + provisioningFeatures.provisioningOptionsEnabled = true; const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); const oauthData: OauthDataDto = new OauthDataDto({ system: new ProvisioningSystemDto({ - systemId: 'systemId', + systemId: new ObjectId().toHexString(), provisioningStrategy: SystemProvisioningStrategy.OIDC, }), externalSchool: externalSchoolDtoFactory.build(), externalUser: new ExternalUserDto({ externalId: externalUserId, }), - externalGroups: externalGroupDtoFactory.buildList(2), + externalGroups, }); const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ externalId: externalUserId, }); - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); + oidcProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + oidcProvisioningService.filterExternalGroups.mockResolvedValueOnce(externalGroups); return { oauthData, @@ -236,7 +254,7 @@ describe('OidcStrategy', () => { describe('when group data is provided, but the feature is disabled', () => { const setup = () => { - Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', false); + provisioningFeatures.schulconnexGroupProvisioningEnabled = false; const externalUserId = 'externalUserId'; const oauthData: OauthDataDto = new OauthDataDto({ @@ -280,7 +298,7 @@ describe('OidcStrategy', () => { describe('when group data is not provided', () => { const setup = () => { - Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', true); + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; const externalUserId = 'externalUserId'; const oauthData: OauthDataDto = new OauthDataDto({ 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 cf277aadf4f..6fe09cd357c 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -1,13 +1,16 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { OauthDataDto, ProvisioningDto } from '../../dto'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; +import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; import { ProvisioningStrategy } from '../base.strategy'; import { OidcProvisioningService } from './service/oidc-provisioning.service'; @Injectable() export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { - constructor(protected readonly oidcProvisioningService: OidcProvisioningService) { + constructor( + @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, + protected readonly oidcProvisioningService: OidcProvisioningService + ) { super(); } @@ -23,7 +26,7 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { school?.id ); - if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { + if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { await this.oidcProvisioningService.removeExternalGroupsAndAffiliation( data.externalUser.externalId, data.externalGroups ?? [], @@ -31,13 +34,15 @@ 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); + } + await Promise.all( - data.externalGroups.map((externalGroup) => - this.oidcProvisioningService.provisionExternalGroup( - externalGroup, - data.externalSchool, - data.system.systemId - ) + groups.map((group: ExternalGroupDto) => + this.oidcProvisioningService.provisionExternalGroup(group, data.externalSchool, data.system.systemId) ) ); } 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 f5d3c0dc7da..6c961a22c96 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 @@ -2,8 +2,14 @@ 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 { Group, GroupService, GroupTypes } from '@modules/group'; +import { + FederalStateService, + LegacySchoolService, + SchoolSystemOptionsService, + SchoolYearService, + SchulConneXProvisioningOptions, +} from '@modules/legacy-school'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; import { UserService } from '@modules/user'; @@ -13,6 +19,7 @@ import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { ExternalSource, LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; import { SchoolFeatures } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { externalGroupDtoFactory, externalSchoolDtoFactory, @@ -43,6 +50,7 @@ describe('OidcProvisioningService', () => { let schoolYearService: DeepMocked; let federalStateService: DeepMocked; let groupService: DeepMocked; + let schoolSystemOptionsService: DeepMocked; let logger: DeepMocked; beforeAll(async () => { @@ -77,6 +85,10 @@ describe('OidcProvisioningService', () => { provide: GroupService, useValue: createMock(), }, + { + provide: SchoolSystemOptionsService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), @@ -92,6 +104,7 @@ describe('OidcProvisioningService', () => { schoolYearService = module.get(SchoolYearService); federalStateService = module.get(FederalStateService); groupService = module.get(GroupService); + schoolSystemOptionsService = module.get(SchoolSystemOptionsService); logger = module.get(Logger); }); @@ -662,6 +675,215 @@ describe('OidcProvisioningService', () => { }); }); + describe('filterExternalGroups', () => { + describe('when all options are on', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + + const classGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.CLASS, + }); + const courseGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.COURSE, + }); + const otherGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.OTHER, + }); + + schoolSystemOptionsService.getProvisioningOptions.mockResolvedValueOnce( + new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: true, + }) + ); + + return { + schoolId, + systemId, + classGroup, + courseGroup, + otherGroup, + }; + }; + + it('should load the configured options from the school', async () => { + const { schoolId, systemId, classGroup, courseGroup, otherGroup } = setup(); + + await service.filterExternalGroups([classGroup, courseGroup, otherGroup], schoolId, systemId); + + expect(schoolSystemOptionsService.getProvisioningOptions).toHaveBeenCalledWith( + SchulConneXProvisioningOptions, + schoolId, + systemId + ); + }); + + it('should not filter', async () => { + const { schoolId, systemId, classGroup, courseGroup, otherGroup } = setup(); + + const result = await service.filterExternalGroups([classGroup, courseGroup, otherGroup], schoolId, systemId); + + expect(result).toEqual([classGroup, courseGroup, otherGroup]); + }); + }); + + describe('when only classes are active', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + + const classGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.CLASS, + }); + const courseGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.COURSE, + }); + const otherGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.OTHER, + }); + + schoolSystemOptionsService.getProvisioningOptions.mockResolvedValueOnce( + new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }) + ); + + return { + schoolId, + systemId, + classGroup, + courseGroup, + otherGroup, + }; + }; + + it('should filter for classes', async () => { + const { schoolId, systemId, classGroup, courseGroup, otherGroup } = setup(); + + const result = await service.filterExternalGroups([classGroup, courseGroup, otherGroup], schoolId, systemId); + + expect(result).toEqual([classGroup]); + }); + }); + + describe('when only courses are active', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + + const classGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.CLASS, + }); + const courseGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.COURSE, + }); + const otherGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.OTHER, + }); + + schoolSystemOptionsService.getProvisioningOptions.mockResolvedValueOnce( + new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningCoursesEnabled: true, + groupProvisioningOtherEnabled: false, + }) + ); + + return { + schoolId, + systemId, + classGroup, + courseGroup, + otherGroup, + }; + }; + + it('should filter for courses', async () => { + const { schoolId, systemId, classGroup, courseGroup, otherGroup } = setup(); + + const result = await service.filterExternalGroups([classGroup, courseGroup, otherGroup], schoolId, systemId); + + expect(result).toEqual([courseGroup]); + }); + }); + + describe('when only other groups are active', () => { + const setup = () => { + const schoolId: EntityId = new ObjectId().toHexString(); + const systemId: EntityId = new ObjectId().toHexString(); + + const classGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.CLASS, + }); + const courseGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.COURSE, + }); + const otherGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.OTHER, + }); + + schoolSystemOptionsService.getProvisioningOptions.mockResolvedValueOnce( + new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: true, + }) + ); + + return { + schoolId, + systemId, + classGroup, + courseGroup, + otherGroup, + }; + }; + + it('should filter for other groups', async () => { + const { schoolId, systemId, classGroup, courseGroup, otherGroup } = setup(); + + const result = await service.filterExternalGroups([classGroup, courseGroup, otherGroup], schoolId, systemId); + + expect(result).toEqual([otherGroup]); + }); + }); + + describe('when no schoolId was provided', () => { + const setup = () => { + const systemId: EntityId = new ObjectId().toHexString(); + + const classGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.CLASS, + }); + const courseGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.COURSE, + }); + const otherGroup: ExternalGroupDto = externalGroupDtoFactory.build({ + type: GroupTypes.OTHER, + }); + + return { + systemId, + classGroup, + courseGroup, + otherGroup, + }; + }; + + it('should use the default option', async () => { + const { systemId, classGroup, courseGroup, otherGroup } = setup(); + + const result = await service.filterExternalGroups([classGroup, courseGroup, otherGroup], undefined, systemId); + + expect(result).toEqual([classGroup]); + }); + }); + }); + describe('provisionExternalGroup', () => { describe('when school for group could not be found', () => { const setup = () => { @@ -1144,7 +1366,7 @@ describe('OidcProvisioningService', () => { const func = async () => service.removeExternalGroupsAndAffiliation(externalUserId, externalGroups, systemId); - await expect(func).rejects.toThrow(new NotFoundLoggableException('User', 'externalId', externalUserId)); + await expect(func).rejects.toThrow(new NotFoundLoggableException('User', { externalId: externalUserId })); }); }); }); 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 98e6abb8223..86d47ee8b82 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,12 @@ -import { AccountService } from '@modules/account/services/account.service'; -import { AccountSaveDto } from '@modules/account/services/dto'; -import { Group, GroupService, GroupUser } from '@modules/group'; -import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { AccountSaveDto, AccountService } from '@modules/account'; +import { Group, GroupService, GroupTypes, GroupUser } from '@modules/group'; +import { + FederalStateService, + LegacySchoolService, + SchoolSystemOptionsService, + SchoolYearService, + SchulConneXProvisioningOptions, +} from '@modules/legacy-school'; import { FederalStateNames } from '@modules/legacy-school/types'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; @@ -27,10 +32,11 @@ export class OidcProvisioningService { private readonly accountService: AccountService, private readonly schoolYearService: SchoolYearService, private readonly federalStateService: FederalStateService, + private readonly schoolSystemOptionsService: SchoolSystemOptionsService, private readonly logger: Logger ) {} - async provisionExternalSchool(externalSchool: ExternalSchoolDto, systemId: EntityId): Promise { + public async provisionExternalSchool(externalSchool: ExternalSchoolDto, systemId: EntityId): Promise { const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolByExternalId( externalSchool.externalId, systemId @@ -76,7 +82,11 @@ export class OidcProvisioningService { return schoolName; } - async provisionExternalUser(externalUser: ExternalUserDto, systemId: EntityId, schoolId?: string): Promise { + public async provisionExternalUser( + externalUser: ExternalUserDto, + systemId: EntityId, + schoolId?: string + ): Promise { let roleRefs: RoleReference[] | undefined; if (externalUser.roles) { const roles: RoleDto[] = await this.roleService.findByNames(externalUser.roles); @@ -130,7 +140,53 @@ export class OidcProvisioningService { return savedUser; } - async provisionExternalGroup( + public async filterExternalGroups( + externalGroups: ExternalGroupDto[], + schoolId: EntityId | undefined, + systemId: EntityId + ): Promise { + let filteredGroups: ExternalGroupDto[] = externalGroups; + + const provisioningOptions: SchulConneXProvisioningOptions = await this.getProvisioningOptionsOrDefault( + schoolId, + systemId + ); + + if (!provisioningOptions.groupProvisioningClassesEnabled) { + filteredGroups = filteredGroups.filter((group: ExternalGroupDto) => group.type !== GroupTypes.CLASS); + } + + if (!provisioningOptions.groupProvisioningCoursesEnabled) { + filteredGroups = filteredGroups.filter((group: ExternalGroupDto) => group.type !== GroupTypes.COURSE); + } + + if (!provisioningOptions.groupProvisioningOtherEnabled) { + filteredGroups = filteredGroups.filter((group: ExternalGroupDto) => group.type !== GroupTypes.OTHER); + } + + return filteredGroups; + } + + private async getProvisioningOptionsOrDefault( + schoolId: string | undefined, + systemId: string + ): Promise { + let provisioningOptions: SchulConneXProvisioningOptions; + + if (schoolId) { + provisioningOptions = await this.schoolSystemOptionsService.getProvisioningOptions( + SchulConneXProvisioningOptions, + schoolId, + systemId + ); + } else { + provisioningOptions = new SchulConneXProvisioningOptions(); + } + + return provisioningOptions; + } + + public async provisionExternalGroup( externalGroup: ExternalGroupDto, externalSchool: ExternalSchoolDto | undefined, systemId: EntityId @@ -178,7 +234,7 @@ export class OidcProvisioningService { const self: GroupUser | null = await this.getGroupUser(externalGroup.user, systemId); if (!self) { - throw new NotFoundLoggableException(UserDO.name, 'externalId', externalGroup.user.externalUserId); + throw new NotFoundLoggableException(UserDO.name, { externalId: externalGroup.user.externalUserId }); } group.addUser(self); @@ -220,7 +276,7 @@ export class OidcProvisioningService { return groupUser; } - async removeExternalGroupsAndAffiliation( + public async removeExternalGroupsAndAffiliation( externalUserId: string, externalGroups: ExternalGroupDto[], systemId: EntityId @@ -228,7 +284,7 @@ export class OidcProvisioningService { const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); if (!user) { - throw new NotFoundLoggableException(UserDO.name, 'externalId', externalUserId); + throw new NotFoundLoggableException(UserDO.name, { externalId: externalUserId }); } const existingGroupsOfUser: Group[] = await this.groupService.findGroupsByUserAndGroupTypes(user); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts index 2341d46d889..f7b9a4fbb0e 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts @@ -1,5 +1,4 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { GroupTypes } from '@modules/group/domain'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; @@ -11,6 +10,7 @@ import { axiosResponseFactory } from '@shared/testing'; import { UUID } from 'bson'; import * as classValidator from 'class-validator'; import { of } from 'rxjs'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, ExternalSchoolDto, @@ -50,6 +50,8 @@ describe('SanisStrategy', () => { ArgsType >; + let provisioningFeatures: IProvisioningFeatures; + beforeAll(async () => { module = await Test.createTestingModule({ providers: [ @@ -66,19 +68,29 @@ describe('SanisStrategy', () => { provide: OidcProvisioningService, useValue: createMock(), }, + { + provide: ProvisioningFeatures, + useValue: {}, + }, ], }).compile(); strategy = module.get(SanisProvisioningStrategy); mapper = module.get(SanisResponseMapper); httpService = module.get(HttpService); + provisioningFeatures = module.get(ProvisioningFeatures); validationFunction = jest.spyOn(classValidator, 'validate'); }); + beforeEach(() => { + Object.assign>(provisioningFeatures, { + schulconnexGroupProvisioningEnabled: true, + }); + }); + afterEach(() => { jest.resetAllMocks(); - Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'true'); }); const setupSanisResponse = (): SanisResponse => { @@ -281,7 +293,7 @@ describe('SanisStrategy', () => { mapper.mapToExternalSchoolDto.mockReturnValue(school); validationFunction.mockResolvedValueOnce([]); - Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'false'); + provisioningFeatures.schulconnexGroupProvisioningEnabled = false; return { input, diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts index 1475411890f..ca8d5942645 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts @@ -1,6 +1,5 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; -import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ValidationErrorLoggableException } from '@shared/common/loggable-exception'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; @@ -8,6 +7,7 @@ import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { plainToClass } from 'class-transformer'; import { validate, ValidationError } from 'class-validator'; import { firstValueFrom } from 'rxjs'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; import { ExternalGroupDto, ExternalSchoolDto, @@ -23,11 +23,12 @@ import { SanisResponseMapper } from './sanis-response.mapper'; @Injectable() export class SanisProvisioningStrategy extends OidcProvisioningStrategy { constructor( + @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, + protected readonly oidcProvisioningService: OidcProvisioningService, private readonly responseMapper: SanisResponseMapper, - private readonly httpService: HttpService, - protected readonly oidcProvisioningService: OidcProvisioningService + private readonly httpService: HttpService ) { - super(oidcProvisioningService); + super(provisioningFeatures, oidcProvisioningService); } getType(): SystemProvisioningStrategy { @@ -67,7 +68,7 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { const externalSchool: ExternalSchoolDto = this.responseMapper.mapToExternalSchoolDto(axiosResponse.data); let externalGroups: ExternalGroupDto[] | undefined; - if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { + if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { await this.checkResponseValidation(response, [SanisResponseValidationGroups.GROUPS]); externalGroups = this.responseMapper.mapToExternalGroupDtos(axiosResponse.data); diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts index e8c56b7d950..de106a751ee 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -1,8 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { DatabaseObjectNotFoundException } from '@mikro-orm/core'; -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { Course, SchoolEntity } from '@shared/domain/entity'; import { CourseService } from '@modules/learnroom/service/course.service'; import { ToolContextType } from '@modules/tool/common/enum'; @@ -13,7 +10,10 @@ import { ExternalToolService } from '@modules/tool/external-tool/service'; import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { SchoolExternalToolService } from '@modules/tool/school-external-tool/service'; import { UserService } from '@modules/user'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo, Pseudonym, UserDO } from '@shared/domain/domainobject'; +import { Course, SchoolEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { contextExternalToolFactory, @@ -168,7 +168,7 @@ describe('FeathersRosterService', () => { const func = service.getUsersMetadata(pseudonym.pseudonym); await expect(func).rejects.toThrow( - new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + new NotFoundLoggableException(UserDO.name, { pseudonym: pseudonym.pseudonym }) ); }); }); @@ -352,7 +352,7 @@ describe('FeathersRosterService', () => { const func = service.getUserGroups(pseudonym.pseudonym, 'externalToolId'); await expect(func).rejects.toThrow( - new NotFoundLoggableException(UserDO.name, 'pseudonym', pseudonym.pseudonym) + new NotFoundLoggableException(UserDO.name, { pseudonym: pseudonym.pseudonym }) ); }); }); @@ -582,7 +582,7 @@ describe('FeathersRosterService', () => { const func = service.getGroup('courseId', 'oauth2ClientId'); await expect(func).rejects.toThrow( - new NotFoundLoggableException(ExternalTool.name, 'config.clientId', 'oauth2ClientId') + new NotFoundLoggableException(ExternalTool.name, { 'config.clientId': 'oauth2ClientId' }) ); }); }); @@ -608,7 +608,7 @@ describe('FeathersRosterService', () => { const func = service.getGroup('courseId', 'oauth2ClientId'); await expect(func).rejects.toThrow( - new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', externalToolId) + new NotFoundLoggableException(SchoolExternalTool.name, { toolId: externalToolId }) ); }); }); @@ -631,7 +631,7 @@ describe('FeathersRosterService', () => { const func = service.getGroup('courseId', 'oauth2ClientId'); await expect(func).rejects.toThrow( - new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', 'courseId') + new NotFoundLoggableException(ContextExternalTool.name, { 'contextRef.id': 'courseId' }) ); }); }); diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts index aad41f42fd5..c98dc52dc2f 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.ts @@ -157,7 +157,7 @@ export class FeathersRosterService { const loadedPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); if (!loadedPseudonym) { - throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + throw new NotFoundLoggableException(Pseudonym.name, { pseudonym }); } return loadedPseudonym; @@ -205,7 +205,7 @@ export class FeathersRosterService { ); if (!externalTool || !externalTool.id) { - throw new NotFoundLoggableException(ExternalTool.name, 'config.clientId', oauth2ClientId); + throw new NotFoundLoggableException(ExternalTool.name, { 'config.clientId': oauth2ClientId }); } return externalTool; @@ -218,7 +218,7 @@ export class FeathersRosterService { }); if (schoolExternalTools.length === 0) { - throw new NotFoundLoggableException(SchoolExternalTool.name, 'toolId', toolId); + throw new NotFoundLoggableException(SchoolExternalTool.name, { toolId }); } } @@ -228,7 +228,7 @@ export class FeathersRosterService { ); if (contextExternalTools.length === 0) { - throw new NotFoundLoggableException(ContextExternalTool.name, 'contextRef.id', courseId); + throw new NotFoundLoggableException(ContextExternalTool.name, { 'contextRef.id': courseId }); } } diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts index 41cc0327b84..36156e0f446 100644 --- a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.ts @@ -21,7 +21,7 @@ export class PseudonymUc { const foundPseudonym: Pseudonym | null = await this.pseudonymService.findPseudonymByPseudonym(pseudonym); if (foundPseudonym === null) { - throw new NotFoundLoggableException(Pseudonym.name, 'pseudonym', pseudonym); + throw new NotFoundLoggableException(Pseudonym.name, { pseudonym }); } const pseudonymUserId: string = foundPseudonym.userId; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index f67cd484545..45b33fefdc7 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -12,6 +12,7 @@ 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'; @@ -31,7 +32,7 @@ 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/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; import connectRedis from 'connect-redis'; @@ -77,6 +78,7 @@ const serverModules = [ TeamsApiModule, MetaTagExtractorApiModule, PseudonymApiModule, + LegacySchoolApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts index f909d3196e9..915cd22b3e0 100644 --- a/apps/server/src/modules/system/domain/system.do.ts +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -25,4 +25,8 @@ export class System extends DomainObject { get ldapConfig(): LdapConfig | undefined { return this.props.ldapConfig; } + + get provisioningStrategy(): SystemProvisioningStrategy | undefined { + return this.props.provisioningStrategy; + } } diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 129faada416..2f28fa6957a 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -44,7 +44,7 @@ export class SystemUc { const system: System | null = await this.systemService.findById(systemId); if (!system) { - throw new NotFoundLoggableException(System.name, 'id', systemId); + throw new NotFoundLoggableException(System.name, { id: systemId }); } const user: User = await this.authorizationService.getUserWithPermissions(userId); 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 4c326aaec85..7eec1701d4c 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 @@ -58,7 +58,7 @@ export class UserLoginMigrationUc { ); if (!userLoginMigration) { - throw new NotFoundLoggableException('UserLoginMigration', 'schoolId', schoolId); + throw new NotFoundLoggableException('UserLoginMigration', { schoolId }); } const user: User = await this.authorizationService.getUserWithPermissions(userId); diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts index a46c13abe70..6f04e0ed246 100644 --- a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.spec.ts @@ -4,21 +4,22 @@ describe('NotFoundLoggableException', () => { describe('getLogMessage', () => { const setup = () => { const resourceName = 'School'; - const identifierName = 'id'; - const resourceId = 'schoolId'; + const identifiers: Record = { + id1: 'testId1', + id2: 'testId2', + }; - const exception = new NotFoundLoggableException(resourceName, identifierName, resourceId); + const exception = new NotFoundLoggableException(resourceName, identifiers); return { exception, resourceName, - identifierName, - resourceId, + identifiers, }; }; it('should log the correct message', () => { - const { exception, resourceName, identifierName, resourceId } = setup(); + const { exception, resourceName, identifiers } = setup(); const result = exception.getLogMessage(); @@ -27,7 +28,7 @@ describe('NotFoundLoggableException', () => { stack: expect.any(String), data: { resourceName, - [identifierName]: resourceId, + ...identifiers, }, }); }); diff --git a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts index 4ffd8e5b70e..55eff273c40 100644 --- a/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts +++ b/apps/server/src/shared/common/loggable-exception/not-found.loggable-exception.ts @@ -3,11 +3,7 @@ import { Loggable } from '@src/core/logger/interfaces'; import { ErrorLogMessage } from '@src/core/logger/types'; export class NotFoundLoggableException extends NotFoundException implements Loggable { - constructor( - private readonly resourceName: string, - private readonly identifierName: string, - private readonly resourceId: string - ) { + constructor(private readonly resourceName: string, private readonly identifiers: Record) { super(); } @@ -17,7 +13,7 @@ export class NotFoundLoggableException extends NotFoundException implements Logg stack: this.stack, data: { resourceName: this.resourceName, - [this.identifierName]: this.resourceId, + ...this.identifiers, }, }; diff --git a/apps/server/src/shared/common/utils/index.ts b/apps/server/src/shared/common/utils/index.ts index 64a60811569..90dadf998ed 100644 --- a/apps/server/src/shared/common/utils/index.ts +++ b/apps/server/src/shared/common/utils/index.ts @@ -1,2 +1,3 @@ export * from './converter.util'; export * from './guard-against'; +export { SortHelper } from './sort-helper'; diff --git a/apps/server/src/modules/group/util/sort-helper.spec.ts b/apps/server/src/shared/common/utils/sort-helper.spec.ts similarity index 97% rename from apps/server/src/modules/group/util/sort-helper.spec.ts rename to apps/server/src/shared/common/utils/sort-helper.spec.ts index 986f32f4f43..2f7ef11d091 100644 --- a/apps/server/src/modules/group/util/sort-helper.spec.ts +++ b/apps/server/src/shared/common/utils/sort-helper.spec.ts @@ -1,4 +1,4 @@ -import { SortOrder } from '@shared/domain/interface'; +import { SortOrder } from '../../domain/interface'; import { SortHelper } from './sort-helper'; describe('SortHelper', () => { diff --git a/apps/server/src/modules/group/util/sort-helper.ts b/apps/server/src/shared/common/utils/sort-helper.ts similarity index 86% rename from apps/server/src/modules/group/util/sort-helper.ts rename to apps/server/src/shared/common/utils/sort-helper.ts index bb2d7008d53..f6726aee67a 100644 --- a/apps/server/src/modules/group/util/sort-helper.ts +++ b/apps/server/src/shared/common/utils/sort-helper.ts @@ -1,7 +1,7 @@ -import { SortOrder } from '@shared/domain/interface'; +import { SortOrder } from '../../domain/interface'; export class SortHelper { - public static genericSortFunction(a: T, b: T, sortOrder: SortOrder): number { + public static genericSortFunction(a: T, b: T, sortOrder: SortOrder = SortOrder.asc): number { let order: number; if (typeof a !== 'undefined' && typeof b === 'undefined') { diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index a666b2850c2..ff5e376a076 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -1,13 +1,14 @@ import { ClassEntity } from '@modules/class/entity'; import { GroupEntity } from '@modules/group/entity'; +import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; +import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; import { DeletionLogEntity, DeletionRequestEntity } from '@src/modules/deletion/entity'; import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; -import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { Account } from './account.entity'; import { BoardNode, @@ -94,6 +95,7 @@ export const ALL_ENTITIES = [ SchoolNews, SchoolRolePermission, SchoolRoles, + SchoolSystemOptionsEntity, SchoolYearEntity, ShareToken, StorageProviderEntity, diff --git a/apps/server/src/shared/domain/entity/school.entity.ts b/apps/server/src/shared/domain/entity/school.entity.ts index cbfbd68988f..8e482c0d8c0 100644 --- a/apps/server/src/shared/domain/entity/school.entity.ts +++ b/apps/server/src/shared/domain/entity/school.entity.ts @@ -1,4 +1,5 @@ import { + Cascade, Collection, Embeddable, Embedded, @@ -6,9 +7,11 @@ import { Index, ManyToMany, ManyToOne, + OneToMany, OneToOne, Property, } from '@mikro-orm/core'; +import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { BaseEntity } from './base.entity'; import { FederalStateEntity } from './federal-state.entity'; @@ -104,6 +107,9 @@ export class SchoolEntity extends BaseEntity { @ManyToOne(() => FederalStateEntity, { fieldName: 'federalState', nullable: false }) federalState: FederalStateEntity; + @OneToMany(() => SchoolSystemOptionsEntity, (options) => options.school, { cascade: [Cascade.REMOVE] }) + schoolSystemOptions = new Collection(this); + constructor(props: SchoolProperties) { super(); if (props.externalId) { diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/shared/domain/entity/system.entity.ts index 3f7f1c46c88..35b84b8bcf6 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/shared/domain/entity/system.entity.ts @@ -1,4 +1,5 @@ -import { Embeddable, Embedded, Entity, Enum, Property } from '@mikro-orm/core'; +import { Cascade, Collection, Embeddable, Embedded, Entity, Enum, OneToMany, Property } from '@mikro-orm/core'; +import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { EntityId } from '../types'; import { BaseEntityWithTimestamps } from './base.entity'; @@ -189,19 +190,6 @@ export class OidcConfigEntity { @Entity({ tableName: 'systems' }) export class SystemEntity extends BaseEntityWithTimestamps { - constructor(props: SystemEntityProps) { - super(); - this.type = props.type; - this.url = props.url; - this.alias = props.alias; - this.displayName = props.displayName; - this.oauthConfig = props.oauthConfig; - this.oidcConfig = props.oidcConfig; - this.ldapConfig = props.ldapConfig; - this.provisioningStrategy = props.provisioningStrategy; - this.provisioningUrl = props.provisioningUrl; - } - @Property({ nullable: false }) type: string; // see legacy enum for valid values @@ -229,4 +217,20 @@ export class SystemEntity extends BaseEntityWithTimestamps { @Property({ nullable: true }) provisioningUrl?: string; + + @OneToMany(() => SchoolSystemOptionsEntity, (options) => options.system, { cascade: [Cascade.REMOVE] }) + schoolSystemOptions = new Collection(this); + + constructor(props: SystemEntityProps) { + super(); + this.type = props.type; + this.url = props.url; + this.alias = props.alias; + this.displayName = props.displayName; + this.oauthConfig = props.oauthConfig; + this.oidcConfig = props.oidcConfig; + this.ldapConfig = props.ldapConfig; + this.provisioningStrategy = props.provisioningStrategy; + this.provisioningUrl = props.provisioningUrl; + } } diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index c3f880101b5..ae46c54dd79 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -107,6 +107,8 @@ export enum Permission { SCHOOL_PERMISSION_CHANGE = 'SCHOOL_PERMISSION_CHANGE', SCHOOL_PERMISSION_VIEW = 'SCHOOL_PERMISSION_VIEW', SCHOOL_STUDENT_TEAM_MANAGE = 'SCHOOL_STUDENT_TEAM_MANAGE', + SCHOOL_SYSTEM_EDIT = 'SCHOOL_SYSTEM_EDIT', + SCHOOL_SYSTEM_VIEW = 'SCHOOL_SYSTEM_VIEW', SCHOOL_TOOL_ADMIN = 'SCHOOL_TOOL_ADMIN', SCOPE_PERMISSIONS_VIEW = 'SCOPE_PERMISSIONS_VIEW', START_MEETING = 'START_MEETING', diff --git a/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts index 4b7f792b67d..b8cc14a7311 100644 --- a/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/system/legacy-system.repo.integration.spec.ts @@ -39,28 +39,6 @@ describe('system repo', () => { await em.nativeDelete(SystemEntity, {}); }); - it('should return right keys', async () => { - const system = systemEntityFactory.build(); - await em.persistAndFlush([system]); - const result = await repo.findById(system.id); - expect(Object.keys(result).sort()).toEqual( - [ - 'createdAt', - 'updatedAt', - 'type', - 'url', - 'alias', - 'displayName', - 'oauthConfig', - 'oidcConfig', - 'ldapConfig', - '_id', - 'provisioningStrategy', - 'provisioningUrl', - ].sort() - ); - }); - it('should return a System that matched by id', async () => { const system = systemEntityFactory.build(); await em.persistAndFlush([system]); diff --git a/apps/server/src/shared/testing/factory/domainobject/index.ts b/apps/server/src/shared/testing/factory/domainobject/index.ts index 9314c2a829e..68a77a221f4 100644 --- a/apps/server/src/shared/testing/factory/domainobject/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/index.ts @@ -8,3 +8,4 @@ export * from './user-login-migration-do.factory'; export * from './lti-tool.factory'; export * from './pseudonym.factory'; export { systemFactory } from './system/system.factory'; +export { schoolSystemOptionsFactory } from './school-system-options/school-system-options.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/school-system-options/school-system-options.factory.ts b/apps/server/src/shared/testing/factory/domainobject/school-system-options/school-system-options.factory.ts new file mode 100644 index 00000000000..0a25afd45b4 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/school-system-options/school-system-options.factory.ts @@ -0,0 +1,24 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { + AnyProvisioningOptions, + SchoolSystemOptions, + SchoolSystemOptionsProps, + SchulConneXProvisioningOptions, +} from '@modules/legacy-school'; +import { DomainObjectFactory } from '../domain-object.factory'; + +export const schoolSystemOptionsFactory = DomainObjectFactory.define< + SchoolSystemOptions, + SchoolSystemOptionsProps +>(SchoolSystemOptions, () => { + return { + id: new ObjectId().toHexString(), + schoolId: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + provisioningOptions: new SchulConneXProvisioningOptions().set({ + groupProvisioningClassesEnabled: true, + groupProvisioningCoursesEnabled: false, + groupProvisioningOtherEnabled: false, + }), + }; +}); diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 645dcb56445..957d2e735a9 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -43,3 +43,4 @@ export * from './axios-error.factory'; export { externalSchoolDtoFactory } from './external-school-dto.factory'; export * from './context-external-tool-configuration-status-response.factory'; export * from './school-tool-configuration-status-response.factory'; +export { schoolSystemOptionsEntityFactory } from './school-system-options-entity.factory'; diff --git a/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts b/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts new file mode 100644 index 00000000000..1cd9aea5ae0 --- /dev/null +++ b/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts @@ -0,0 +1,20 @@ +import { SchoolSystemOptionsEntity, SchoolSystemOptionsEntityProps } from '@modules/legacy-school/entity'; +import { SystemProvisioningStrategy } from '../../domain/interface/system-provisioning.strategy'; +import { BaseFactory } from './base.factory'; +import { schoolFactory } from './school.factory'; +import { systemEntityFactory } from './systemEntityFactory'; + +export const schoolSystemOptionsEntityFactory = BaseFactory.define< + SchoolSystemOptionsEntity, + SchoolSystemOptionsEntityProps +>(SchoolSystemOptionsEntity, () => { + return { + school: schoolFactory.buildWithId(), + system: systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }), + provisioningOptions: { + groupProvisioningOtherEnabled: false, + groupProvisioningCoursesEnabled: false, + groupProvisioningClassesEnabled: false, + }, + }; +}); diff --git a/apps/server/src/shared/testing/user-role-permissions.ts b/apps/server/src/shared/testing/user-role-permissions.ts index b1fd11955a9..151b9788b5c 100644 --- a/apps/server/src/shared/testing/user-role-permissions.ts +++ b/apps/server/src/shared/testing/user-role-permissions.ts @@ -131,6 +131,8 @@ export const adminPermissions = [ Permission.SCHOOL_LOGO_MANAGE, Permission.SCHOOL_CHAT_MANAGE, Permission.SCHOOL_STUDENT_TEAM_MANAGE, + Permission.SCHOOL_SYSTEM_EDIT, + Permission.SCHOOL_SYSTEM_VIEW, Permission.SYSTEM_EDIT, Permission.SYNC_START, Permission.SCHOOL_PERMISSION_VIEW, diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 84c010b593d..b0288b2b975 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -361,5 +361,16 @@ "$date": "2023-12-05T12:20:50.147Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "6576dbdb91b90e92b8ce9b9c" + }, + "state": "up", + "name": "add-school-system-view-and-edit", + "createdAt": { + "$date": "2023-12-11T09:52:27.346Z" + }, + "__v": 0 } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 9ceaa532f1d..774cecef365 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -135,7 +135,9 @@ "START_MEETING", "JOIN_MEETING", "GROUP_LIST", - "GROUP_FULL_ADMIN" + "GROUP_FULL_ADMIN", + "SCHOOL_SYSTEM_EDIT", + "SCHOOL_SYSTEM_VIEW" ], "__v": 2 }, diff --git a/backup/setup/school-system-options.json b/backup/setup/school-system-options.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/backup/setup/school-system-options.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backup/setup/systems.json b/backup/setup/systems.json index 96e5fa43850..0e9dce42bf2 100644 --- a/backup/setup/systems.json +++ b/backup/setup/systems.json @@ -55,5 +55,15 @@ }, "active": true } + }, + { + "_id": { + "$oid": "62c7f233f35a554ba3ed0000" + }, + "type": "ldap", + "alias": "cy-general-ldap-system", + "ldapConfig": { + "provider": "general" + } } ] diff --git a/config/default.schema.json b/config/default.schema.json index 5671879cb48..c3ef3115a95 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1364,6 +1364,11 @@ "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/migrations/1702288347346-add-school-system-view-and-edit.js b/migrations/1702288347346-add-school-system-view-and-edit.js new file mode 100644 index 00000000000..8a75c0eb2a2 --- /dev/null +++ b/migrations/1702288347346-add-school-system-view-and-edit.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); +const { alert } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +const Roles = mongoose.model( + 'roles202312111053', + new mongoose.Schema( + { + name: { type: String, required: true }, + permissions: [{ type: String }], + }, + { + timestamps: true, + } + ), + 'roles' +); + +module.exports = { + up: async function up() { + await connect(); + + const adminRole = await Roles.updateOne( + { name: 'administrator' }, + { + $addToSet: { + permissions: { + $each: ['SCHOOL_SYSTEM_EDIT', 'SCHOOL_SYSTEM_VIEW'], + }, + }, + } + ).exec(); + + if (adminRole) { + alert('Permission SCHOOL_SYSTEM_EDIT and SCHOOL_SYSTEM_VIEW were added to role administrator'); + } + + await close(); + }, + + down: async function down() { + await connect(); + + const adminRole = await Roles.updateOne( + { name: 'administrator' }, + { + $pull: { + permissions: { + $in: ['SCHOOL_SYSTEM_EDIT', 'SCHOOL_SYSTEM_VIEW'], + }, + }, + } + ).exec(); + + if (adminRole) { + alert('Rollback: Removed permission SCHOOL_SYSTEM_EDIT and SCHOOL_SYSTEM_VIEW from role administrator'); + } + + await close(); + }, +}; diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js index cdf6a815a25..0b429a46048 100644 --- a/src/services/config/publicAppConfigService.js +++ b/src/services/config/publicAppConfigService.js @@ -65,6 +65,7 @@ const exposedVars = [ 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', 'FEATURE_TLDRAW_ENABLED', + 'FEATURE_PROVISIONING_OPTIONS_ENABLED', ]; /** From 1f6ddc83b45a74e4b18e7451c6b36c7012a2b230 Mon Sep 17 00:00:00 2001 From: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> Date: Fri, 15 Dec 2023 17:23:49 +0100 Subject: [PATCH 2/4] BC-5942 - dedicated ConfigMap and Secret for the Admin API deployment (#4644) * add loading Admin API server secret from 1Password * fix incorrect comment * add custom configmap for the Admin API server, add task that will deploy it * switch to the custom configmap and secret for the Admin API deployment * add ADMIN_API__PORT env to the Admin API server ConfigMap * change invalid refs names * modify Admin API server config map data * move Rocket.Chat URI from the configmap to the secrets (for the Admin API server) --- .../roles/schulcloud-server-core/tasks/main.yml | 14 ++++++++++++++ .../templates/admin-api-server-configmap.yml.j2 | 13 +++++++++++++ .../templates/admin-api-server-deployment.yml.j2 | 4 ++-- .../templates/admin-api-server-onepassword.yml.j2 | 9 +++++++++ .../templates/admin-api-server-svc.yml.j2 | 2 +- 5 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 create mode 100644 ansible/roles/schulcloud-server-core/templates/admin-api-server-onepassword.yml.j2 diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 048da2580c8..6a83839a2fe 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,6 +37,20 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API server ConfigMap + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: admin-api-server-configmap.yml.j2 + apply: yes + + - name: Admin API server Secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: admin-api-server-onepassword.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin API client secret (from 1Password) kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 new file mode 100644 index 00000000000..5726812014d --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: admin-api-server-configmap + namespace: {{ NAMESPACE }} + labels: + app: api-admin +data: + NODE_OPTIONS: "--max-old-space-size=3072" + NEST_LOG_LEVEL: "info" + ADMIN_API__PORT: "4030" + SC_DOMAIN: "{{ DOMAIN }}" + FEATURE_PROMETHEUS_METRICS_ENABLED: "true" diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-deployment.yml.j2 index ef0076fd15e..c0d911fb4ca 100644 --- a/ansible/roles/schulcloud-server-core/templates/admin-api-server-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-deployment.yml.j2 @@ -51,9 +51,9 @@ spec: protocol: TCP envFrom: - configMapRef: - name: api-configmap + name: admin-api-server-configmap - secretRef: - name: api-secret + name: admin-api-server-secret command: ['npm', 'run', 'nest:start:admin-api-server:prod'] resources: limits: diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-onepassword.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-onepassword.yml.j2 new file mode 100644 index 00000000000..8f5583bc122 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: admin-api-server-secret + namespace: {{ NAMESPACE }} + labels: + app: api-admin +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/admin-api-server" diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-svc.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-svc.yml.j2 index cde6dcef4cd..8a1c44c14cd 100644 --- a/ansible/roles/schulcloud-server-core/templates/admin-api-server-svc.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-svc.yml.j2 @@ -8,7 +8,7 @@ metadata: spec: type: ClusterIP ports: - # port for http managing drawing data + # Admin API server port. - port: 4030 targetPort: 4030 protocol: TCP From e72dd50c4b6fa0d0d5e48dcd205a99da4b23df8e Mon Sep 17 00:00:00 2001 From: sszafGCA <116172610+sszafGCA@users.noreply.github.com> Date: Fri, 15 Dec 2023 18:34:24 +0100 Subject: [PATCH 3/4] BC-5907-fix problem with blocking the sending of specific e-mail domain parts --- apps/server/src/infra/mail/interfaces/mail-config.ts | 2 +- apps/server/src/infra/mail/mail.service.ts | 2 +- apps/server/src/modules/server/server.config.ts | 2 +- config/default.schema.json | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/infra/mail/interfaces/mail-config.ts b/apps/server/src/infra/mail/interfaces/mail-config.ts index d4baab878e7..28696edcae3 100644 --- a/apps/server/src/infra/mail/interfaces/mail-config.ts +++ b/apps/server/src/infra/mail/interfaces/mail-config.ts @@ -1,3 +1,3 @@ export interface MailConfig { - ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: string[]; + BLOCKLIST_OF_EMAIL_DOMAINS: string[]; } diff --git a/apps/server/src/infra/mail/mail.service.ts b/apps/server/src/infra/mail/mail.service.ts index ce8f68ceddb..efb72aa9293 100644 --- a/apps/server/src/infra/mail/mail.service.ts +++ b/apps/server/src/infra/mail/mail.service.ts @@ -18,7 +18,7 @@ export class MailService { @Inject('MAIL_SERVICE_OPTIONS') private readonly options: MailServiceOptions, private readonly configService: ConfigService ) { - this.domainBlacklist = this.configService.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS'); + this.domainBlacklist = this.configService.get('BLOCKLIST_OF_EMAIL_DOMAINS'); } public async send(data: Mail): Promise { diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index e5fb0e2188e..c3c0789d344 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -46,7 +46,7 @@ const config: ServerConfig = { ADMIN_API__ALLOWED_API_KEYS: (Configuration.get('ADMIN_API__ALLOWED_API_KEYS') as string) .split(',') .map((apiKey) => apiKey.trim()), - ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS: (Configuration.get('ADDITIONAL_BLACKLISTED_EMAIL_DOMAINS') as string) + BLOCKLIST_OF_EMAIL_DOMAINS: (Configuration.get('BLOCKLIST_OF_EMAIL_DOMAINS') as string) .split(',') .map((domain) => domain.trim()), }; diff --git a/config/default.schema.json b/config/default.schema.json index c3ef3115a95..0104563c2d2 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -180,6 +180,11 @@ "default": "", "description": "Add custom domain to the list of blocked domains (comma separated list)." }, + "BLOCKLIST_OF_EMAIL_DOMAINS":{ + "type": "string", + "default": "", + "description": "Add custom domain to the list of blocked domains (comma separated list)." + }, "FEATURE_TSP_AUTO_CONSENT_ENABLED": { "type": "boolean", "default": false, From 32720d30da77adbeca9a3a15f16104e001f278d7 Mon Sep 17 00:00:00 2001 From: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:28:07 +0100 Subject: [PATCH 4/4] BC-5834-Add-Dashboard-Entities-Deletion-to-the-Main-User-Deletion-Use-Case (#4622) * add method in dashboard repo * add dashboard service * modify service, add some test * add test for dashboardService * Update apps/server/src/modules/learnroom/service/dashboard.service.spec.ts Co-authored-by: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> * 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/learnroom/service/dashboard.service.ts Co-authored-by: Sergej Hoffmann <97111299+SevenWaysDP@users.noreply.github.com> * changes after revieew * change name of variable * add imports * fix imports * add test to DashboardGridElementModelRepo --------- Co-authored-by: Bartosz Nowicki <116367402+bn-pass@users.noreply.github.com> 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 | 21 +++- .../deletion/uc/deletion-request.uc.ts | 19 ++- apps/server/src/modules/learnroom/index.ts | 1 + .../src/modules/learnroom/learnroom.module.ts | 22 +++- .../service/dashboard.service.spec.ts | 118 ++++++++++++++++++ .../learnroom/service/dashboard.service.ts | 19 +++ .../src/modules/learnroom/service/index.ts | 1 + .../dashboard.repo.integration.spec.ts | 42 +++++++ .../shared/repo/dashboard/dashboard.repo.ts | 7 ++ .../dashboard/dashboardElement.repo.spec.ts | 91 ++++++++++++++ .../repo/dashboard/dashboardElement.repo.ts | 22 ++++ .../server/src/shared/repo/dashboard/index.ts | 1 + 13 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/modules/learnroom/service/dashboard.service.spec.ts create mode 100644 apps/server/src/modules/learnroom/service/dashboard.service.ts create mode 100644 apps/server/src/shared/repo/dashboard/dashboardElement.repo.spec.ts create mode 100644 apps/server/src/shared/repo/dashboard/dashboardElement.repo.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 daa4985498d..148e1c71e8e 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 @@ -3,6 +3,7 @@ export const enum DeletionDomainModel { CLASS = 'class', COURSEGROUP = 'courseGroup', COURSE = 'course', + DASHBOARD = 'dashboard', FILE = 'file', LESSONS = 'lessons', PSEUDONYMS = 'pseudonyms', 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 9e2f2604f16..feea12da31e 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 @@ -3,7 +3,7 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { setupEntities, userDoFactory } from '@shared/testing'; import { AccountService } from '@modules/account'; import { ClassService } from '@modules/class'; -import { CourseGroupService, CourseService } from '@modules/learnroom'; +import { CourseGroupService, CourseService, DashboardService } from '@modules/learnroom'; import { FilesService } from '@modules/files'; import { LessonService } from '@modules/lesson'; import { PseudonymService } from '@modules/pseudonym'; @@ -40,6 +40,7 @@ describe(DeletionRequestUc.name, () => { let rocketChatUserService: DeepMocked; let rocketChatService: DeepMocked; let registrationPinService: DeepMocked; + let dashboardService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -105,6 +106,10 @@ describe(DeletionRequestUc.name, () => { provide: RegistrationPinService, useValue: createMock(), }, + { + provide: DashboardService, + useValue: createMock(), + }, ], }).compile(); @@ -123,6 +128,7 @@ describe(DeletionRequestUc.name, () => { rocketChatUserService = module.get(RocketChatUserService); rocketChatService = module.get(RocketChatService); registrationPinService = module.get(RegistrationPinService); + dashboardService = module.get(DashboardService); await setupEntities(); }); @@ -199,6 +205,7 @@ describe(DeletionRequestUc.name, () => { teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); userService.deleteUser.mockResolvedValueOnce(1); rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); + dashboardService.deleteDashboardByUserId.mockResolvedValueOnce(1); return { deletionRequestToExecute, @@ -381,6 +388,16 @@ describe(DeletionRequestUc.name, () => { expect(rocketChatService.deleteUser).toHaveBeenCalledWith(rocketChatUser.username); }); + it('should call dashboardService.deleteDashboardByUserId to delete USERS DASHBOARD', async () => { + const { deletionRequestToExecute } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + + await uc.executeDeletionRequests(); + + expect(dashboardService.deleteDashboardByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); + }); + it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { const { deletionRequestToExecute } = setup(); @@ -388,7 +405,7 @@ describe(DeletionRequestUc.name, () => { await uc.executeDeletionRequests(); - expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(10); + expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(11); }); }); 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 100029887ca..afb56be3193 100644 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts @@ -1,7 +1,7 @@ import { AccountService } from '@modules/account/services'; import { ClassService } from '@modules/class'; import { FilesService } from '@modules/files/service'; -import { CourseGroupService, CourseService } from '@modules/learnroom/service'; +import { CourseGroupService, CourseService, DashboardService } from '@modules/learnroom'; import { LessonService } from '@modules/lesson/service'; import { PseudonymService } from '@modules/pseudonym'; import { RegistrationPinService } from '@modules/registration-pin'; @@ -36,7 +36,8 @@ export class DeletionRequestUc { private readonly rocketChatUserService: RocketChatUserService, private readonly rocketChatService: RocketChatService, private readonly logger: LegacyLogger, - private readonly registrationPinService: RegistrationPinService + private readonly registrationPinService: RegistrationPinService, + private readonly dashboardService: DashboardService ) { this.logger.setContext(DeletionRequestUc.name); } @@ -105,6 +106,7 @@ export class DeletionRequestUc { this.removeUser(deletionRequest), this.removeUserFromRocketChat(deletionRequest), this.removeUserRegistrationPin(deletionRequest), + this.removeUsersDashboard(deletionRequest), ]); await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); } catch (error) { @@ -198,6 +200,19 @@ export class DeletionRequestUc { ); } + private async removeUsersDashboard(deletionRequest: DeletionRequest) { + this.logger.debug({ action: 'removeUsersDashboard', deletionRequest }); + + const dashboardDeleted: number = await this.dashboardService.deleteDashboardByUserId(deletionRequest.targetRefId); + await this.logDeletion( + deletionRequest, + DeletionDomainModel.DASHBOARD, + DeletionOperationModel.DELETE, + 0, + dashboardDeleted + ); + } + private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { this.logger.debug({ action: 'removeUsersFilesAndPermissions', deletionRequest }); diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index 6c28dcb2e95..94cbf86ff33 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -5,4 +5,5 @@ export { CourseService, RoomsService, CourseGroupService, + DashboardService, } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index 9c9d4d80036..f78d74dfafa 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -3,7 +3,15 @@ import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; import { TaskModule } from '@modules/task'; import { Module } from '@nestjs/common'; -import { BoardRepo, CourseGroupRepo, CourseRepo, DashboardModelMapper, DashboardRepo, UserRepo } from '@shared/repo'; +import { + BoardRepo, + CourseGroupRepo, + CourseRepo, + DashboardElementRepo, + DashboardModelMapper, + DashboardRepo, + UserRepo, +} from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { BoardCopyService, @@ -12,6 +20,7 @@ import { CourseCopyService, CourseGroupService, CourseService, + DashboardService, RoomsService, } from './service'; @@ -22,6 +31,7 @@ import { provide: 'DASHBOARD_REPO', useClass: DashboardRepo, }, + DashboardElementRepo, DashboardModelMapper, CourseRepo, BoardRepo, @@ -34,7 +44,15 @@ import { ColumnBoardTargetService, CourseGroupService, CourseGroupRepo, + DashboardService, + ], + exports: [ + CourseCopyService, + CourseService, + RoomsService, + CommonCartridgeExportService, + CourseGroupService, + DashboardService, ], - exports: [CourseCopyService, CourseService, RoomsService, CommonCartridgeExportService, CourseGroupService], }) export class LearnroomModule {} diff --git a/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts b/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts new file mode 100644 index 00000000000..00f8207e23c --- /dev/null +++ b/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts @@ -0,0 +1,118 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardElementRepo, IDashboardRepo, UserRepo } from '@shared/repo'; +import { setupEntities, userFactory } from '@shared/testing'; +import { LearnroomMetadata, LearnroomTypes } from '@shared/domain/types'; +import { DashboardEntity, GridElement } from '@shared/domain/entity'; +import { DashboardService } from '.'; + +const learnroomMock = (id: string, name: string) => { + return { + getMetadata(): LearnroomMetadata { + return { + id, + type: LearnroomTypes.Course, + title: name, + shortTitle: name.substr(0, 2), + displayColor: '#ACACAC', + }; + }, + }; +}; + +describe(DashboardService.name, () => { + let module: TestingModule; + let userRepo: DeepMocked; + let dashboardRepo: IDashboardRepo; + let dashboardElementRepo: DeepMocked; + let dashboardService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + DashboardService, + { + provide: UserRepo, + useValue: createMock(), + }, + { + provide: 'DASHBOARD_REPO', + useValue: createMock(), + }, + { + provide: DashboardElementRepo, + useValue: createMock(), + }, + ], + }).compile(); + dashboardService = module.get(DashboardService); + userRepo = module.get(UserRepo); + dashboardRepo = module.get('DASHBOARD_REPO'); + dashboardElementRepo = module.get(DashboardElementRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when deleting by userId', () => { + const setup = () => { + const user = userFactory.buildWithId(); + userRepo.findById.mockResolvedValue(user); + + return { user }; + }; + + it('should call dashboardRepo.getUsersDashboard', async () => { + const { user } = setup(); + const spy = jest.spyOn(dashboardRepo, 'getUsersDashboard'); + + await dashboardService.deleteDashboardByUserId(user.id); + + expect(spy).toHaveBeenCalledWith(user.id); + }); + + it('should call dashboardElementRepo.deleteByDashboardId', async () => { + const { user } = setup(); + jest.spyOn(dashboardRepo, 'getUsersDashboard').mockResolvedValueOnce( + new DashboardEntity('dashboardId', { + grid: [ + { + pos: { x: 1, y: 2 }, + gridElement: GridElement.FromPersistedReference('elementId', learnroomMock('referenceId', 'Mathe')), + }, + ], + userId: 'userId', + }) + ); + const spy = jest.spyOn(dashboardElementRepo, 'deleteByDashboardId'); + + await dashboardService.deleteDashboardByUserId(user.id); + + expect(spy).toHaveBeenCalledWith('dashboardId'); + }); + + it('should call dashboardRepo.deleteDashboardByUserId', async () => { + const { user } = setup(); + const spy = jest.spyOn(dashboardRepo, 'deleteDashboardByUserId'); + + await dashboardService.deleteDashboardByUserId(user.id); + + expect(spy).toHaveBeenCalledWith(user.id); + }); + + it('should delete users dashboard', async () => { + const { user } = setup(); + jest.spyOn(dashboardRepo, 'deleteDashboardByUserId').mockImplementation(() => Promise.resolve(1)); + + const result = await dashboardService.deleteDashboardByUserId(user.id); + + expect(result).toEqual(1); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/service/dashboard.service.ts b/apps/server/src/modules/learnroom/service/dashboard.service.ts new file mode 100644 index 00000000000..4a7910f0991 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/dashboard.service.ts @@ -0,0 +1,19 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { IDashboardRepo, DashboardElementRepo } from '@shared/repo'; + +@Injectable() +export class DashboardService { + constructor( + @Inject('DASHBOARD_REPO') private readonly dashboardRepo: IDashboardRepo, + private readonly dashboardElementRepo: DashboardElementRepo + ) {} + + async deleteDashboardByUserId(userId: EntityId): Promise { + const usersDashboard = await this.dashboardRepo.getUsersDashboard(userId); + await this.dashboardElementRepo.deleteByDashboardId(usersDashboard.id); + const result = await this.dashboardRepo.deleteDashboardByUserId(userId); + + return result; + } +} diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index ca9d75634cf..f5e9336abcf 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -5,3 +5,4 @@ export * from './common-cartridge-export.service'; export * from './course.service'; export * from './rooms.service'; export * from './coursegroup.service'; +export * from './dashboard.service'; diff --git a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts index 82e4c2dd03d..d82502ab351 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts @@ -244,4 +244,46 @@ describe('dashboard repo', () => { }); }); }); + + describe('deleteDashboardByUserId', () => { + const setup = async () => { + const userWithoutDashoard = userFactory.build(); + const user = userFactory.build(); + const course = courseFactory.build({ students: [user], name: 'Mathe' }); + await em.persistAndFlush([userWithoutDashoard, user, course]); + const dashboard = new DashboardEntity(new ObjectId().toString(), { + grid: [ + { + pos: { x: 1, y: 3 }, + gridElement: GridElement.FromSingleReference(course), + }, + ], + userId: user.id, + }); + await repo.persistAndFlush(dashboard); + + return { userWithoutDashoard, user }; + }; + describe('when user has no dashboard ', () => { + it('should return 0', async () => { + const { userWithoutDashoard } = await setup(); + + const result = await repo.deleteDashboardByUserId(userWithoutDashoard.id); + expect(result).toEqual(0); + }); + }); + + describe('when user has dashboard ', () => { + it('should return 1', async () => { + const { user } = await setup(); + + const result1 = await repo.deleteDashboardByUserId(user.id); + expect(result1).toEqual(1); + + const result2 = await repo.getUsersDashboard(user.id); + expect(result2 instanceof DashboardEntity).toEqual(true); + expect(result2.getGrid().length).toEqual(0); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/dashboard/dashboard.repo.ts b/apps/server/src/shared/repo/dashboard/dashboard.repo.ts index 925a7760eba..2ddb7599921 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.repo.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.repo.ts @@ -15,6 +15,7 @@ export interface IDashboardRepo { getUsersDashboard(userId: EntityId): Promise; getDashboardById(id: EntityId): Promise; persistAndFlush(entity: DashboardEntity): Promise; + deleteDashboardByUserId(userId: EntityId): Promise; } @Injectable() @@ -51,4 +52,10 @@ export class DashboardRepo implements IDashboardRepo { return dashboard; } + + async deleteDashboardByUserId(userId: EntityId): Promise { + const promise: Promise = this.em.nativeDelete(DashboardModelEntity, { user: userId }); + + return promise; + } } diff --git a/apps/server/src/shared/repo/dashboard/dashboardElement.repo.spec.ts b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.spec.ts new file mode 100644 index 00000000000..7c416193b2b --- /dev/null +++ b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.spec.ts @@ -0,0 +1,91 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardGridElementModel, DashboardModelEntity } from '@shared/domain/entity'; +import { courseFactory, userFactory } from '@shared/testing'; +import { DashboardElementRepo } from './dashboardElement.repo'; + +describe(DashboardElementRepo.name, () => { + let repo: DashboardElementRepo; + let em: EntityManager; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [DashboardElementRepo], + }).compile(); + + repo = module.get(DashboardElementRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + expect(typeof repo.deleteByDashboardId).toEqual('function'); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(DashboardGridElementModel); + }); + }); + + describe('deleteByDasboardId', () => { + const setup = async () => { + const user1 = userFactory.build(); + const user2 = userFactory.build(); + const course = courseFactory.build({ students: [user1], name: 'Mathe' }); + await em.persistAndFlush([user1, course]); + + const dashboard = new DashboardModelEntity({ id: new ObjectId().toString(), user: user1 }); + const dashboardWithoutDashboardElement = new DashboardModelEntity({ id: new ObjectId().toString(), user: user2 }); + + const element = new DashboardGridElementModel({ + id: new ObjectId().toString(), + xPos: 1, + yPos: 2, + references: [course], + dashboard, + }); + + dashboard.gridElements.add(element); + + await em.persistAndFlush([dashboard, dashboardWithoutDashboardElement]); + em.clear(); + + return { dashboard, dashboardWithoutDashboardElement }; + }; + + describe('when user has no dashboardElement ', () => { + it('should return 0', async () => { + const { dashboardWithoutDashboardElement } = await setup(); + + const result = await repo.deleteByDashboardId(dashboardWithoutDashboardElement.id); + expect(result).toEqual(0); + }); + }); + + describe('when user has some dashboardElement on dashboard ', () => { + it('should return 1', async () => { + const { dashboard } = await setup(); + + const result1 = await repo.deleteByDashboardId(dashboard.id); + expect(result1).toEqual(1); + + const result2 = await em.findOne(DashboardGridElementModel, { + dashboard: dashboard.id, + }); + expect(result2).toEqual(null); + }); + }); + }); +}); diff --git a/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts new file mode 100644 index 00000000000..27f5baf93f2 --- /dev/null +++ b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/core'; +import { DashboardGridElementModel } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { ObjectId } from 'bson'; + +@Injectable() +export class DashboardElementRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return DashboardGridElementModel; + } + + async deleteByDashboardId(id: EntityId): Promise { + const promise = this.em.nativeDelete(DashboardGridElementModel, { + dashboard: new ObjectId(id), + }); + + return promise; + } +} diff --git a/apps/server/src/shared/repo/dashboard/index.ts b/apps/server/src/shared/repo/dashboard/index.ts index 7802371f925..ff7c943af5d 100644 --- a/apps/server/src/shared/repo/dashboard/index.ts +++ b/apps/server/src/shared/repo/dashboard/index.ts @@ -1,2 +1,3 @@ export * from './dashboard.repo'; +export * from './dashboardElement.repo'; export * from './dashboard.model.mapper';