diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 3d1f19bc312..f180ea0519f 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -30,6 +30,10 @@ export class Group extends DomainObject { return this.props.users; } + set users(value: GroupUser[]) { + this.props.users = value; + } + get externalSource(): ExternalSource | undefined { return this.props.externalSource; } diff --git a/apps/server/src/modules/provisioning/dto/external-group.dto.ts b/apps/server/src/modules/provisioning/dto/external-group.dto.ts index e01093fa4ea..bc33ae6cd73 100644 --- a/apps/server/src/modules/provisioning/dto/external-group.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-group.dto.ts @@ -6,7 +6,9 @@ export class ExternalGroupDto { name: string; - users: ExternalGroupUserDto[]; + user: ExternalGroupUserDto; + + otherUsers?: ExternalGroupUserDto[]; from: Date; @@ -14,15 +16,13 @@ export class ExternalGroupDto { type: GroupTypes; - externalOrganizationId?: string; - constructor(props: ExternalGroupDto) { this.externalId = props.externalId; this.name = props.name; - this.users = props.users; + this.user = props.user; + this.otherUsers = props.otherUsers; this.from = props.from; this.until = props.until; this.type = props.type; - this.externalOrganizationId = props.externalOrganizationId; } } diff --git a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts index 205c6481529..888a6a58514 100644 --- a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts +++ b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.spec.ts @@ -1,35 +1,25 @@ +import { externalSchoolDtoFactory } from '@shared/testing'; import { externalGroupDtoFactory } from '@shared/testing/factory/external-group-dto.factory'; -import { ExternalGroupDto } from '../dto'; +import { ExternalGroupDto, ExternalSchoolDto } from '../dto'; import { SchoolForGroupNotFoundLoggable } from './school-for-group-not-found.loggable'; describe('SchoolForGroupNotFoundLoggable', () => { - describe('constructor', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); - - return { externalGroupDto }; - }; - - it('should create an instance of UserForGroupNotFoundLoggable', () => { - const { externalGroupDto } = setup(); - - const loggable = new SchoolForGroupNotFoundLoggable(externalGroupDto); - - expect(loggable).toBeInstanceOf(SchoolForGroupNotFoundLoggable); - }); - }); - describe('getLogMessage', () => { const setup = () => { const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); + const externalSchoolDto: ExternalSchoolDto = externalSchoolDtoFactory.build(); - const loggable = new SchoolForGroupNotFoundLoggable(externalGroupDto); + const loggable = new SchoolForGroupNotFoundLoggable(externalGroupDto, externalSchoolDto); - return { loggable, externalGroupDto }; + return { + loggable, + externalGroupDto, + externalSchoolDto, + }; }; it('should return a loggable message', () => { - const { loggable, externalGroupDto } = setup(); + const { loggable, externalGroupDto, externalSchoolDto } = setup(); const message = loggable.getLogMessage(); @@ -37,7 +27,7 @@ describe('SchoolForGroupNotFoundLoggable', () => { message: 'Unable to provision group, since the connected school cannot be found.', data: { externalGroupId: externalGroupDto.externalId, - externalOrganizationId: externalGroupDto.externalOrganizationId, + externalOrganizationId: externalSchoolDto.externalId, }, }); }); diff --git a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts index 5fd8dd1f59e..af87d7b346e 100644 --- a/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts +++ b/apps/server/src/modules/provisioning/loggable/school-for-group-not-found.loggable.ts @@ -1,15 +1,15 @@ import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { ExternalGroupDto } from '../dto'; +import { ExternalGroupDto, ExternalSchoolDto } from '../dto'; export class SchoolForGroupNotFoundLoggable implements Loggable { - constructor(private readonly group: ExternalGroupDto) {} + constructor(private readonly group: ExternalGroupDto, private readonly school: ExternalSchoolDto) {} getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { message: 'Unable to provision group, since the connected school cannot be found.', data: { externalGroupId: this.group.externalId, - externalOrganizationId: this.group.externalOrganizationId, + externalOrganizationId: this.school.externalId, }, }; } 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 e4f50429d0f..9437336e9da 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,11 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, RoleName, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, userDoFactory } from '@shared/testing'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { externalGroupDtoFactory } from '@shared/testing/factory/external-group-dto.factory'; +import { externalSchoolDtoFactory } from '@shared/testing/factory/external-school-dto.factory'; import { ExternalSchoolDto, ExternalUserDto, @@ -181,6 +182,7 @@ describe('OidcStrategy', () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.OIDC, }), + externalSchool: externalSchoolDtoFactory.build(), externalUser: new ExternalUserDto({ externalId: externalUserId, }), @@ -217,10 +219,12 @@ describe('OidcStrategy', () => { expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( oauthData.externalGroups?.[0], + oauthData.externalSchool, oauthData.system.systemId ); expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( oauthData.externalGroups?.[1], + oauthData.externalSchool, oauthData.system.systemId ); }); 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 4cb3e920da6..4c1aa33e067 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts @@ -33,7 +33,11 @@ export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { if (data.externalGroups) { await Promise.all( data.externalGroups.map((externalGroup) => - this.oidcProvisioningService.provisionExternalGroup(externalGroup, data.system.systemId) + this.oidcProvisioningService.provisionExternalGroup( + externalGroup, + 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 c313be9973d..7e03caa3422 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 @@ -22,6 +22,7 @@ import { schoolYearFactory, userDoFactory, } from '@shared/testing'; +import { externalSchoolDtoFactory } from '@shared/testing/factory/external-school-dto.factory'; import { Logger } from '@src/core/logger'; import CryptoJS from 'crypto-js'; import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; @@ -661,6 +662,293 @@ describe('OidcProvisioningService', () => { }); describe('provisionExternalGroup', () => { + describe('when school for group could not be found', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); + const externalSchoolDto: ExternalSchoolDto = externalSchoolDtoFactory.build(); + const systemId = 'systemId'; + schoolService.getSchoolByExternalId.mockResolvedValueOnce(null); + + return { + externalSchoolDto, + externalGroupDto, + systemId, + }; + }; + + it('should log a SchoolForGroupNotFoundLoggable', async () => { + const { externalGroupDto, externalSchoolDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, externalSchoolDto, systemId); + + expect(logger.info).toHaveBeenCalledWith( + new SchoolForGroupNotFoundLoggable(externalGroupDto, externalSchoolDto) + ); + }); + + it('should not call groupService.save', async () => { + const { externalGroupDto, externalSchoolDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, externalSchoolDto, systemId); + + expect(groupService.save).not.toHaveBeenCalled(); + }); + }); + + describe('when the user cannot be found', () => { + const setup = () => { + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ otherUsers: undefined }); + const systemId = 'systemId'; + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); + + userService.findByExternalId.mockResolvedValue(null); + schoolService.getSchoolByExternalId.mockResolvedValue(school); + + return { + externalGroupDto, + systemId, + }; + }; + + it('should log a UserForGroupNotFoundLoggable', async () => { + const { externalGroupDto, systemId } = setup(); + + await expect(service.provisionExternalGroup(externalGroupDto, undefined, systemId)).rejects.toThrow(); + + expect(logger.info).toHaveBeenCalledWith(new UserForGroupNotFoundLoggable(externalGroupDto.user)); + }); + + it('should throw a not found exception', async () => { + const { externalGroupDto, systemId } = setup(); + + await expect(service.provisionExternalGroup(externalGroupDto, undefined, systemId)).rejects.toThrow( + NotFoundLoggableException + ); + }); + }); + + describe('when provisioning a new group with other group members', () => { + const setup = () => { + const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: 'schoolId' }); + const student: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build({ id: new ObjectId().toHexString(), externalId: 'studentExternalId' }); + const teacher: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.TEACHER }]) + .build({ id: new ObjectId().toHexString(), externalId: 'teacherExternalId' }); + const studentRole: RoleDto = roleDtoFactory.build({ name: RoleName.STUDENT }); + const teacherRole: RoleDto = roleDtoFactory.build({ name: RoleName.TEACHER }); + const externalSchoolDto: ExternalSchoolDto = externalSchoolDtoFactory.build(); + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ + user: { + externalUserId: student.externalId as string, + roleName: RoleName.STUDENT, + }, + otherUsers: [ + { + externalUserId: teacher.externalId as string, + roleName: RoleName.TEACHER, + }, + ], + }); + const systemId = new ObjectId().toHexString(); + + schoolService.getSchoolByExternalId.mockResolvedValueOnce(school); + groupService.findByExternalSource.mockResolvedValueOnce(null); + userService.findByExternalId.mockResolvedValueOnce(student); + roleService.findByNames.mockResolvedValueOnce([studentRole]); + userService.findByExternalId.mockResolvedValueOnce(teacher); + roleService.findByNames.mockResolvedValueOnce([teacherRole]); + + return { + externalSchoolDto, + externalGroupDto, + school, + student, + teacher, + studentRole, + teacherRole, + systemId, + }; + }; + + it('should use the correct school', async () => { + const { externalGroupDto, externalSchoolDto, systemId } = setup(); + + await service.provisionExternalGroup(externalGroupDto, externalSchoolDto, systemId); + + expect(schoolService.getSchoolByExternalId).toHaveBeenCalledWith(externalSchoolDto.externalId, systemId); + }); + + it('should save a new group', async () => { + const { externalGroupDto, externalSchoolDto, school, student, studentRole, teacher, teacherRole, systemId } = + setup(); + + await service.provisionExternalGroup(externalGroupDto, externalSchoolDto, systemId); + + expect(groupService.save).toHaveBeenCalledWith({ + props: { + id: expect.any(String), + name: externalGroupDto.name, + externalSource: { + externalId: externalGroupDto.externalId, + systemId, + }, + type: externalGroupDto.type, + organizationId: school.id, + validFrom: externalGroupDto.from, + validUntil: externalGroupDto.until, + users: [ + { + userId: student.id, + roleId: studentRole.id, + }, + { + userId: teacher.id, + roleId: teacherRole.id, + }, + ], + }, + }); + }); + }); + + describe('when provisioning an existing group without other group members', () => { + const setup = () => { + const student: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build({ id: new ObjectId().toHexString(), externalId: 'studentExternalId' }); + const teacherId = new ObjectId().toHexString(); + const teacherRoleId = new ObjectId().toHexString(); + const teacher: UserDO = userDoFactory + .withRoles([{ id: teacherRoleId, name: RoleName.TEACHER }]) + .build({ id: teacherId, externalId: 'teacherExternalId' }); + const studentRole: RoleDto = roleDtoFactory.build({ name: RoleName.STUDENT }); + const teacherRole: RoleDto = roleDtoFactory.build({ id: teacherRoleId, name: RoleName.TEACHER }); + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ + user: { + externalUserId: student.externalId as string, + roleName: RoleName.STUDENT, + }, + otherUsers: undefined, + }); + const group: Group = groupFactory.build({ users: [{ userId: teacherId, roleId: teacherRoleId }] }); + const systemId = new ObjectId().toHexString(); + + groupService.findByExternalSource.mockResolvedValueOnce(group); + userService.findByExternalId.mockResolvedValueOnce(student); + roleService.findByNames.mockResolvedValueOnce([studentRole]); + + return { + externalGroupDto, + student, + teacher, + studentRole, + teacherRole, + systemId, + group, + }; + }; + + it('should update the group and only add the user', async () => { + const { externalGroupDto, student, studentRole, teacher, teacherRole, systemId, group } = setup(); + + await service.provisionExternalGroup(externalGroupDto, undefined, systemId); + + expect(groupService.save).toHaveBeenCalledWith({ + props: { + id: group.id, + name: externalGroupDto.name, + externalSource: { + externalId: externalGroupDto.externalId, + systemId, + }, + type: externalGroupDto.type, + organizationId: undefined, + validFrom: externalGroupDto.from, + validUntil: externalGroupDto.until, + users: [ + { + userId: teacher.id, + roleId: teacherRole.id, + }, + { + userId: student.id, + roleId: studentRole.id, + }, + ], + }, + }); + }); + }); + + describe('when provisioning an existing group with no other group members', () => { + const setup = () => { + const student: UserDO = userDoFactory + .withRoles([{ id: new ObjectId().toHexString(), name: RoleName.STUDENT }]) + .build({ id: new ObjectId().toHexString(), externalId: 'studentExternalId' }); + const teacherId = new ObjectId().toHexString(); + const teacherRoleId = new ObjectId().toHexString(); + const teacher: UserDO = userDoFactory + .withRoles([{ id: teacherRoleId, name: RoleName.TEACHER }]) + .build({ id: teacherId, externalId: 'teacherExternalId' }); + const studentRole: RoleDto = roleDtoFactory.build({ name: RoleName.STUDENT }); + const teacherRole: RoleDto = roleDtoFactory.build({ id: teacherRoleId, name: RoleName.TEACHER }); + const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ + user: { + externalUserId: student.externalId as string, + roleName: RoleName.STUDENT, + }, + otherUsers: [], + }); + const group: Group = groupFactory.build({ users: [{ userId: teacherId, roleId: teacherRoleId }] }); + const systemId = new ObjectId().toHexString(); + + groupService.findByExternalSource.mockResolvedValueOnce(group); + userService.findByExternalId.mockResolvedValueOnce(student); + roleService.findByNames.mockResolvedValueOnce([studentRole]); + + return { + externalGroupDto, + student, + teacher, + studentRole, + teacherRole, + systemId, + group, + }; + }; + + it('should update the group with all users', async () => { + const { externalGroupDto, student, studentRole, systemId, group } = setup(); + + await service.provisionExternalGroup(externalGroupDto, undefined, systemId); + + expect(groupService.save).toHaveBeenCalledWith({ + props: { + id: group.id, + name: externalGroupDto.name, + externalSource: { + externalId: externalGroupDto.externalId, + systemId, + }, + type: externalGroupDto.type, + organizationId: undefined, + validFrom: externalGroupDto.from, + validUntil: externalGroupDto.until, + users: [ + { + userId: student.id, + roleId: studentRole.id, + }, + ], + }, + }); + }); + }); + }); + + describe('removeExternalGroupsAndAffiliation', () => { describe('when group membership of user has not changed', () => { const setup = () => { const systemId = 'systemId'; @@ -674,11 +962,11 @@ describe('OidcProvisioningService', () => { const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ externalId: existingGroups[0].externalSource?.externalId, - users: [{ externalUserId, roleName: role.name }], + user: { externalUserId, roleName: role.name }, }); const secondExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ externalId: existingGroups[1].externalSource?.externalId, - users: [{ externalUserId, roleName: role.name }], + user: { externalUserId, roleName: role.name }, }); const externalGroups: ExternalGroupDto[] = [firstExternalGroup, secondExternalGroup]; @@ -735,7 +1023,7 @@ describe('OidcProvisioningService', () => { const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ externalId: existingGroups[0].externalSource?.externalId, - users: [{ externalUserId, roleName: role.name }], + user: { externalUserId, roleName: role.name }, }); const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; @@ -802,7 +1090,7 @@ describe('OidcProvisioningService', () => { const firstExternalGroup: ExternalGroupDto = externalGroupDtoFactory.build({ externalId: existingGroups[0].externalSource?.externalId, - users: [{ externalUserId, roleName: role.name }], + user: { externalUserId, roleName: role.name }, }); const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; @@ -858,199 +1146,5 @@ describe('OidcProvisioningService', () => { await expect(func).rejects.toThrow(new NotFoundLoggableException('User', 'externalId', externalUserId)); }); }); - - describe('when the group has no users', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ users: [] }); - - return { - externalGroupDto, - }; - }; - - it('should not create a group', async () => { - const { externalGroupDto } = setup(); - - await service.provisionExternalGroup(externalGroupDto, 'systemId'); - - expect(groupService.save).not.toHaveBeenCalled(); - }); - }); - - describe('when group does not have an externalOrganizationId', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ externalOrganizationId: undefined }); - - return { - externalGroupDto, - }; - }; - - it('should not call schoolService.getSchoolByExternalId', async () => { - const { externalGroupDto } = setup(); - - await service.provisionExternalGroup(externalGroupDto, 'systemId'); - - expect(schoolService.getSchoolByExternalId).not.toHaveBeenCalled(); - }); - }); - - describe('when school for group could not be found', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ externalOrganizationId: 'orgaId' }); - const systemId = 'systemId'; - schoolService.getSchoolByExternalId.mockResolvedValueOnce(null); - - return { - externalGroupDto, - systemId, - }; - }; - - it('should log a SchoolForGroupNotFoundLoggable', async () => { - const { externalGroupDto, systemId } = setup(); - - await service.provisionExternalGroup(externalGroupDto, systemId); - - expect(logger.info).toHaveBeenCalledWith(new SchoolForGroupNotFoundLoggable(externalGroupDto)); - }); - - it('should not call groupService.save', async () => { - const { externalGroupDto, systemId } = setup(); - - await service.provisionExternalGroup(externalGroupDto, systemId); - - expect(groupService.save).not.toHaveBeenCalled(); - }); - }); - - describe('when externalGroup has no users', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ - users: [], - }); - - return { - externalGroupDto, - }; - }; - - it('should not call userService.findByExternalId', async () => { - const { externalGroupDto } = setup(); - - await service.provisionExternalGroup(externalGroupDto, 'systemId'); - - expect(userService.findByExternalId).not.toHaveBeenCalled(); - }); - - it('should not call roleService.findByNames', async () => { - const { externalGroupDto } = setup(); - - await service.provisionExternalGroup(externalGroupDto, 'systemId'); - - expect(roleService.findByNames).not.toHaveBeenCalled(); - }); - }); - - describe('when externalGroupUser could not been found', () => { - const setup = () => { - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build(); - const systemId = 'systemId'; - const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - - userService.findByExternalId.mockResolvedValue(null); - schoolService.getSchoolByExternalId.mockResolvedValue(school); - - return { - externalGroupDto, - systemId, - }; - }; - - it('should log a UserForGroupNotFoundLoggable', async () => { - const { externalGroupDto, systemId } = setup(); - - await service.provisionExternalGroup(externalGroupDto, systemId); - - expect(logger.info).toHaveBeenCalledWith(new UserForGroupNotFoundLoggable(externalGroupDto.users[0])); - }); - }); - - describe('when provision group', () => { - const setup = () => { - const group: Group = groupFactory.build({ users: [] }); - groupService.findByExternalSource.mockResolvedValue(group); - - const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: 'schoolId' }); - schoolService.getSchoolByExternalId.mockResolvedValue(school); - - const student: UserDO = userDoFactory - .withRoles([{ id: 'studentRoleId', name: RoleName.STUDENT }]) - .build({ id: 'studentId', externalId: 'studentExternalId' }); - const teacher: UserDO = userDoFactory - .withRoles([{ id: 'teacherRoleId', name: RoleName.TEACHER }]) - .build({ id: 'teacherId', externalId: 'teacherExternalId' }); - userService.findByExternalId.mockResolvedValueOnce(student); - userService.findByExternalId.mockResolvedValueOnce(teacher); - const studentRole: RoleDto = roleDtoFactory.build({ name: RoleName.STUDENT }); - const teacherRole: RoleDto = roleDtoFactory.build({ name: RoleName.TEACHER }); - roleService.findByNames.mockResolvedValueOnce([studentRole]); - roleService.findByNames.mockResolvedValueOnce([teacherRole]); - const externalGroupDto: ExternalGroupDto = externalGroupDtoFactory.build({ - users: [ - { - externalUserId: student.externalId as string, - roleName: RoleName.STUDENT, - }, - { - externalUserId: teacher.externalId as string, - roleName: RoleName.TEACHER, - }, - ], - }); - const systemId = 'systemId'; - - return { - externalGroupDto, - school, - student, - teacher, - studentRole, - teacherRole, - systemId, - }; - }; - - it('should save a new group', async () => { - const { externalGroupDto, school, student, studentRole, teacher, teacherRole, systemId } = setup(); - - await service.provisionExternalGroup(externalGroupDto, systemId); - - expect(groupService.save).toHaveBeenCalledWith({ - props: { - id: expect.any(String), - name: externalGroupDto.name, - externalSource: { - externalId: externalGroupDto.externalId, - systemId, - }, - type: externalGroupDto.type, - organizationId: school.id, - validFrom: externalGroupDto.from, - validUntil: externalGroupDto.until, - users: [ - { - userId: student.id, - roleId: studentRole.id, - }, - { - userId: teacher.id, - roleId: teacherRole.id, - }, - ], - }, - }); - }); - }); }); }); 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 52b0e7472e2..36335353a18 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 @@ -129,35 +129,33 @@ export class OidcProvisioningService { return savedUser; } - async provisionExternalGroup(externalGroup: ExternalGroupDto, systemId: EntityId): Promise { - const existingGroup: Group | null = await this.groupService.findByExternalSource( - externalGroup.externalId, - systemId - ); - + async provisionExternalGroup( + externalGroup: ExternalGroupDto, + externalSchool: ExternalSchoolDto | undefined, + systemId: EntityId + ): Promise { let organizationId: string | undefined; - if (externalGroup.externalOrganizationId) { + if (externalSchool) { const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolByExternalId( - externalGroup.externalOrganizationId, + externalSchool.externalId, systemId ); if (!existingSchool || !existingSchool.id) { - this.logger.info(new SchoolForGroupNotFoundLoggable(externalGroup)); + this.logger.info(new SchoolForGroupNotFoundLoggable(externalGroup, externalSchool)); return; } organizationId = existingSchool.id; } - const users: GroupUser[] = await this.getFilteredGroupUsers(externalGroup, systemId); - - if (!users.length) { - return; - } + const existingGroup: Group | null = await this.groupService.findByExternalSource( + externalGroup.externalId, + systemId + ); const group: Group = new Group({ - id: existingGroup ? existingGroup.id : new ObjectId().toHexString(), + id: existingGroup?.id ?? new ObjectId().toHexString(), name: externalGroup.name, externalSource: new ExternalSource({ externalId: externalGroup.externalId, @@ -167,31 +165,36 @@ export class OidcProvisioningService { organizationId, validFrom: externalGroup.from, validUntil: externalGroup.until, - users: existingGroup ? existingGroup.users : [], + users: existingGroup?.users ?? [], }); - users.forEach((user: GroupUser) => group.addUser(user)); + + if (externalGroup.otherUsers !== undefined) { + const otherUsers: GroupUser[] = await this.getFilteredGroupUsers(externalGroup, systemId); + + group.users = otherUsers; + } + + const self: GroupUser | null = await this.getGroupUser(externalGroup.user, systemId); + + if (!self) { + throw new NotFoundLoggableException(UserDO.name, 'externalId', externalGroup.user.externalUserId); + } + + group.addUser(self); await this.groupService.save(group); } private async getFilteredGroupUsers(externalGroup: ExternalGroupDto, systemId: string): Promise { - const users: (GroupUser | null)[] = await Promise.all( - externalGroup.users.map(async (externalGroupUser: ExternalGroupUserDto): Promise => { - const user: UserDO | null = await this.userService.findByExternalId(externalGroupUser.externalUserId, systemId); - const roles: RoleDto[] = await this.roleService.findByNames([externalGroupUser.roleName]); - - if (!user?.id || roles.length !== 1 || !roles[0].id) { - this.logger.info(new UserForGroupNotFoundLoggable(externalGroupUser)); - return null; - } - - const groupUser: GroupUser = new GroupUser({ - userId: user.id, - roleId: roles[0].id, - }); + if (!externalGroup.otherUsers) { + return []; + } - return groupUser; - }) + const users: (GroupUser | null)[] = await Promise.all( + externalGroup.otherUsers.map( + async (externalGroupUser: ExternalGroupUserDto): Promise => + this.getGroupUser(externalGroupUser, systemId) + ) ); const filteredUsers: GroupUser[] = users.filter((groupUser): groupUser is GroupUser => groupUser !== null); @@ -199,8 +202,25 @@ export class OidcProvisioningService { return filteredUsers; } + private async getGroupUser(externalGroupUser: ExternalGroupUserDto, systemId: EntityId): Promise { + const user: UserDO | null = await this.userService.findByExternalId(externalGroupUser.externalUserId, systemId); + const roles: RoleDto[] = await this.roleService.findByNames([externalGroupUser.roleName]); + + if (!user?.id || roles.length !== 1 || !roles[0].id) { + this.logger.info(new UserForGroupNotFoundLoggable(externalGroupUser)); + return null; + } + + const groupUser: GroupUser = new GroupUser({ + userId: user.id, + roleId: roles[0].id, + }); + + return groupUser; + } + async removeExternalGroupsAndAffiliation( - externalUserId: EntityId, + externalUserId: string, externalGroups: ExternalGroupDto[], systemId: EntityId ): Promise { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts index 0aa20be24dc..25b437cb102 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts @@ -2,5 +2,6 @@ import { SanisGroupRole } from './sanis-group-role'; export interface SanisSonstigeGruppenzugehoerigeResponse { ktid: string; + rollen: SanisGroupRole[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index c560d4f7c3a..d273c75dd64 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -12,6 +12,7 @@ import { SanisPersonenkontextResponse, SanisResponse, SanisRole, + SanisSonstigeGruppenzugehoerigeResponse, } from './response'; import { SanisResponseMapper } from './sanis-response.mapper'; @@ -159,32 +160,37 @@ describe('SanisResponseMapper', () => { const { sanisResponse } = setupSanisResponse(); const personenkontext: SanisPersonenkontextResponse = sanisResponse.personenkontexte[0]; const group: SanisGruppenResponse = personenkontext.gruppen![0]; + const otherParticipant: SanisSonstigeGruppenzugehoerigeResponse = group.sonstige_gruppenzugehoerige![0]; return { sanisResponse, group, personenkontext, + otherParticipant, }; }; it('should map the sanis response to external group dtos', () => { - const { sanisResponse, group, personenkontext } = setup(); + const { sanisResponse, group, personenkontext, otherParticipant } = setup(); const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0]).toEqual({ + expect(result?.[0]).toEqual({ name: group.gruppe.bezeichnung, type: GroupTypes.CLASS, - externalOrganizationId: personenkontext.organisation.id, from: group.gruppe.laufzeit.von, until: group.gruppe.laufzeit.bis, externalId: group.gruppe.id, - users: [ + user: { + externalUserId: personenkontext.id, + roleName: RoleName.TEACHER, + }, + otherUsers: [ { - externalUserId: personenkontext.id, - roleName: RoleName.TEACHER, + externalUserId: otherParticipant.ktid, + roleName: RoleName.STUDENT, }, - ].sort((a, b) => a.externalUserId.localeCompare(b.externalUserId)), + ], }); }); }); @@ -199,7 +205,7 @@ describe('SanisResponseMapper', () => { }; }; - it('should return empty array', () => { + it('should not map the group', () => { const { sanisResponse } = setup(); const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); @@ -208,7 +214,7 @@ describe('SanisResponseMapper', () => { }); }); - describe('when a group role mapping is missing', () => { + describe('when the group role mapping for the user is missing', () => { const setup = () => { const { sanisResponse } = setupSanisResponse(); sanisResponse.personenkontexte[0].gruppen![0]!.gruppenzugehoerigkeit.rollen = [SanisGroupRole.SCHOOL_SUPPORT]; @@ -218,16 +224,35 @@ describe('SanisResponseMapper', () => { }; }; - it('should return only users with known roles', () => { + it('should not map the group', () => { const { sanisResponse } = setup(); const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0].users).toHaveLength(0); + expect(result).toHaveLength(0); }); }); - describe('when a group has no other participants', () => { + describe('when the user has no role in the group', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen![0]!.gruppenzugehoerigkeit.rollen = []; + + return { + sanisResponse, + }; + }; + + it('should not map the group', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result).toHaveLength(0); + }); + }); + + describe('when no other participants are provided', () => { const setup = () => { const { sanisResponse } = setupSanisResponse(); sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = undefined; @@ -237,12 +262,36 @@ describe('SanisResponseMapper', () => { }; }; - it('should return the group with only the user', () => { + it('should set other users to undefined', () => { + const { sanisResponse } = setup(); + + const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); + + expect(result?.[0].otherUsers).toBeUndefined(); + }); + }); + + describe('when other participants have unknown roles', () => { + const setup = () => { + const { sanisResponse } = setupSanisResponse(); + sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = [ + { + ktid: 'ktid', + rollen: [SanisGroupRole.SCHOOL_SUPPORT], + }, + ]; + + return { + sanisResponse, + }; + }; + + it('should not add the user to other users', () => { const { sanisResponse } = setup(); const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0].users).toHaveLength(1); + expect(result?.[0].otherUsers).toHaveLength(0); }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 3ca0d06806e..c8de317ab1f 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -75,34 +75,39 @@ export class SanisResponseMapper { } const mapped: ExternalGroupDto[] = groups - .map((group): ExternalGroupDto | null => { + .map((group: SanisGruppenResponse): ExternalGroupDto | null => { const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; if (!groupType) { return null; } - const sanisGroupUsers: SanisSonstigeGruppenzugehoerigeResponse[] = [ - { - ktid: source.personenkontexte[0].id, - rollen: group.gruppenzugehoerigkeit.rollen, - }, - ].filter((sanisGroupUser) => sanisGroupUser.ktid && sanisGroupUser.rollen); + const user: ExternalGroupUserDto | null = this.mapToExternalGroupUser({ + ktid: source.personenkontexte[0].id, + rollen: group.gruppenzugehoerigkeit.rollen, + }); - const gruppenzugehoerigkeiten: ExternalGroupUserDto[] = sanisGroupUsers - .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) - .filter((user): user is ExternalGroupUserDto => user !== null); + if (!user) { + return null; + } - const externalOrganizationId = source.personenkontexte[0].organisation?.id; + let otherUsers: ExternalGroupUserDto[] | undefined; + if (group.sonstige_gruppenzugehoerige) { + otherUsers = group.sonstige_gruppenzugehoerige + .map((relation: SanisSonstigeGruppenzugehoerigeResponse): ExternalGroupUserDto | null => + this.mapToExternalGroupUser(relation) + ) + .filter((otherUser: ExternalGroupUserDto | null): otherUser is ExternalGroupUserDto => otherUser !== null); + } return new ExternalGroupDto({ name: group.gruppe.bezeichnung, type: groupType, - externalOrganizationId, from: group.gruppe.laufzeit?.von, until: group.gruppe.laufzeit?.bis, externalId: group.gruppe.id, - users: gruppenzugehoerigkeiten, + user, + otherUsers, }); }) .filter((group): group is ExternalGroupDto => group !== null); @@ -111,7 +116,12 @@ export class SanisResponseMapper { } private mapToExternalGroupUser(relation: SanisSonstigeGruppenzugehoerigeResponse): ExternalGroupUserDto | null { - const userRole = GroupRoleMapping[relation.rollen[0]]; + if (!relation.rollen?.length) { + this.logger.info(new GroupRoleUnknownLoggable(relation)); + return null; + } + + const userRole: RoleName | undefined = GroupRoleMapping[relation.rollen[0]]; if (!userRole) { this.logger.info(new GroupRoleUnknownLoggable(relation)); diff --git a/apps/server/src/shared/testing/factory/external-group-dto.factory.ts b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts index 562b68f8767..5f2fdbdb010 100644 --- a/apps/server/src/shared/testing/factory/external-group-dto.factory.ts +++ b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts @@ -1,29 +1,25 @@ +import { GroupTypes } from '@modules/group'; +import { ExternalGroupDto } from '@modules/provisioning/dto'; import { RoleName } from '@shared/domain'; import { ObjectId } from 'bson'; -import { ExternalGroupDto } from '@modules/provisioning/dto'; -import { GroupTypes } from '@modules/group'; -import { BaseFactory } from './base.factory'; +import { Factory } from 'fishery'; -export const externalGroupDtoFactory = BaseFactory.define( - ExternalGroupDto, - ({ sequence }) => { - return { - externalId: new ObjectId().toHexString(), - name: `Group ${sequence}`, - type: GroupTypes.CLASS, - users: [ - { - externalUserId: new ObjectId().toHexString(), - roleName: RoleName.TEACHER, - }, - { - externalUserId: new ObjectId().toHexString(), - roleName: RoleName.STUDENT, - }, - ], - from: new Date(2023, 1), - until: new Date(2023, 6), - externalOrganizationId: new ObjectId().toHexString(), - }; - } -); +export const externalGroupDtoFactory = Factory.define(({ sequence }) => { + return { + externalId: new ObjectId().toHexString(), + name: `Group ${sequence}`, + type: GroupTypes.CLASS, + user: { + externalUserId: new ObjectId().toHexString(), + roleName: RoleName.TEACHER, + }, + otherUsers: [ + { + externalUserId: new ObjectId().toHexString(), + roleName: RoleName.STUDENT, + }, + ], + from: new Date(2023, 1), + until: new Date(2023, 6), + }; +}); diff --git a/apps/server/src/shared/testing/factory/external-school-dto.factory.ts b/apps/server/src/shared/testing/factory/external-school-dto.factory.ts new file mode 100644 index 00000000000..4e9e3d1989f --- /dev/null +++ b/apps/server/src/shared/testing/factory/external-school-dto.factory.ts @@ -0,0 +1,10 @@ +import { ExternalSchoolDto } from '@modules/provisioning/dto'; +import { ObjectId } from 'bson'; +import { Factory } from 'fishery'; + +export const externalSchoolDtoFactory = Factory.define(({ sequence }) => { + return { + externalId: new ObjectId().toHexString(), + name: `External School ${sequence}`, + }; +}); diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 54fac672098..24dac296de2 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -40,3 +40,4 @@ export * from './user.factory'; export * from './legacy-file-entity-mock.factory'; export * from './jwt.test.factory'; export * from './axios-error.factory'; +export { externalSchoolDtoFactory } from './external-school-dto.factory';