diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 27225804515..6438b4a3a56 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -31,6 +31,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..793740ba4de 100644 --- a/apps/server/src/modules/provisioning/dto/external-group.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-group.dto.ts @@ -6,23 +6,23 @@ export class ExternalGroupDto { name: string; - users: ExternalGroupUserDto[]; + user: ExternalGroupUserDto; - from: Date; + otherUsers?: ExternalGroupUserDto[]; - until: Date; + from?: Date; - type: GroupTypes; + until?: Date; - externalOrganizationId?: string; + type: GroupTypes; 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/group-role-unknown.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts index 04b2ad3cba5..47447c99103 100644 --- a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts @@ -1,26 +1,7 @@ -import { SanisGroupRole, SanisSonstigeGruppenzugehoerigeResponse } from '../strategy/sanis/response'; +import { SanisGroupRole, SanisSonstigeGruppenzugehoerigeResponse } from '../strategy'; import { GroupRoleUnknownLoggable } from './group-role-unknown.loggable'; describe('GroupRoleUnknownLoggable', () => { - describe('constructor', () => { - const setup = () => { - const sanisSonstigeGruppenzugehoerigeResponse: SanisSonstigeGruppenzugehoerigeResponse = { - ktid: 'ktid', - rollen: [SanisGroupRole.TEACHER], - }; - - return { sanisSonstigeGruppenzugehoerigeResponse }; - }; - - it('should create an instance of UserForGroupNotFoundLoggable', () => { - const { sanisSonstigeGruppenzugehoerigeResponse } = setup(); - - const loggable = new GroupRoleUnknownLoggable(sanisSonstigeGruppenzugehoerigeResponse); - - expect(loggable).toBeInstanceOf(GroupRoleUnknownLoggable); - }); - }); - describe('getLogMessage', () => { const setup = () => { const sanisSonstigeGruppenzugehoerigeResponse: SanisSonstigeGruppenzugehoerigeResponse = { @@ -42,7 +23,7 @@ describe('GroupRoleUnknownLoggable', () => { message: 'Unable to add unknown user to group during provisioning.', data: { externalUserId: sanisSonstigeGruppenzugehoerigeResponse.ktid, - externalRoleName: sanisSonstigeGruppenzugehoerigeResponse.rollen[0], + externalRoleName: sanisSonstigeGruppenzugehoerigeResponse.rollen?.[0], }, }); }); diff --git a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts index a146a7011b3..0eb43237060 100644 --- a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts @@ -9,7 +9,7 @@ export class GroupRoleUnknownLoggable implements Loggable { message: 'Unable to add unknown user to group during provisioning.', data: { externalUserId: this.relation.ktid, - externalRoleName: this.relation.rollen[0], + externalRoleName: this.relation.rollen?.[0], }, }; } 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 bbba0ba9009..2d369fa40e9 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 @@ -5,8 +5,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { legacySchoolDoFactory, userDoFactory } from '@shared/testing'; -import { externalGroupDtoFactory } from '@shared/testing/factory/external-group-dto.factory'; +import { + externalGroupDtoFactory, + externalSchoolDtoFactory, + legacySchoolDoFactory, + userDoFactory, +} from '@shared/testing'; import { ExternalSchoolDto, ExternalUserDto, @@ -182,6 +186,7 @@ describe('OidcStrategy', () => { systemId: 'systemId', provisioningStrategy: SystemProvisioningStrategy.OIDC, }), + externalSchool: externalSchoolDtoFactory.build(), externalUser: new ExternalUserDto({ externalId: externalUserId, }), @@ -218,10 +223,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 eb54b862a2b..cf277aadf4f 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 06cedba863d..6f8a2e29ab7 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 @@ -15,6 +15,7 @@ import { SchoolFeatures } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { externalGroupDtoFactory, + externalSchoolDtoFactory, federalStateFactory, groupFactory, legacySchoolDoFactory, @@ -487,7 +488,7 @@ describe('OidcProvisioningService', () => { externalId: 'externalId', name: 'existingName', officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [], + systems: undefined, features: [SchoolFeatures.OAUTH_PROVISIONING_ENABLED], }); @@ -662,6 +663,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 empty 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'; @@ -675,11 +963,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]; @@ -736,7 +1024,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]; @@ -803,7 +1091,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]; @@ -859,199 +1147,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 7d37199c1aa..a96eba16dcb 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 @@ -130,35 +130,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, @@ -168,31 +166,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?.length) { + 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); @@ -200,8 +203,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/index.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts index bb8cc6e8d07..bf4151eb116 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts @@ -5,10 +5,10 @@ export * from './sanis-group-type'; export * from './sanis-name-response'; export * from './sanis-gruppe-response'; export * from './sanis-gruppen-response'; -export * from './sanis-laufzeit-response'; export * from './sanis-organisation-response'; export * from './sanis-personenkontext-response'; export * from './sanis-gruppenzugehoerigkeit-response'; export * from './sanis-person-response'; export * from './sanis-sonstige-gruppenzugehoerige-response'; export * from './sanis-anschrift-response'; +export * from './sanis-response-validation-groups'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts index 6b793ba4486..429bb7b937d 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts @@ -1,9 +1,7 @@ -export interface SanisAnschriftResponse { - adresszeile?: string; - - postleitzahl?: string; +import { IsOptional, IsString } from 'class-validator'; +export class SanisAnschriftResponse { + @IsString() + @IsOptional() ort?: string; - - ortsteil?: string; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts index 618be9dd9e6..0f3834fba6c 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts @@ -1,3 +1,7 @@ -export interface SanisGeburtResponse { +import { IsOptional, IsString } from 'class-validator'; + +export class SanisGeburtResponse { + @IsOptional() + @IsString() datum?: string; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts index 93d8af0e884..0a785f73633 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts @@ -1,14 +1,13 @@ +import { IsEnum, IsString } from 'class-validator'; import { SanisGroupType } from './sanis-group-type'; -import { SanisLaufzeitResponse } from './sanis-laufzeit-response'; -export interface SanisGruppeResponse { - id: string; +export class SanisGruppeResponse { + @IsString() + id!: string; - bezeichnung: string; + @IsString() + bezeichnung!: string; - typ: SanisGroupType; - - orgid: string; - - laufzeit: SanisLaufzeitResponse; + @IsEnum(SanisGroupType) + typ!: SanisGroupType; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts index 8154676b8f2..ce48cc90120 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts @@ -1,11 +1,23 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsObject, IsOptional, ValidateNested } from 'class-validator'; import { SanisGruppeResponse } from './sanis-gruppe-response'; import { SanisGruppenzugehoerigkeitResponse } from './sanis-gruppenzugehoerigkeit-response'; import { SanisSonstigeGruppenzugehoerigeResponse } from './sanis-sonstige-gruppenzugehoerige-response'; -export interface SanisGruppenResponse { - gruppe: SanisGruppeResponse; +export class SanisGruppenResponse { + @IsObject() + @ValidateNested() + @Type(() => SanisGruppeResponse) + gruppe!: SanisGruppeResponse; - gruppenzugehoerigkeit: SanisGruppenzugehoerigkeitResponse; + @IsObject() + @ValidateNested() + @Type(() => SanisGruppenzugehoerigkeitResponse) + gruppenzugehoerigkeit!: SanisGruppenzugehoerigkeitResponse; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SanisSonstigeGruppenzugehoerigeResponse) sonstige_gruppenzugehoerige?: SanisSonstigeGruppenzugehoerigeResponse[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts index 16098edd6bf..44e45d3c7df 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts @@ -1,5 +1,9 @@ +import { IsArray, IsEnum, IsOptional } from 'class-validator'; import { SanisGroupRole } from './sanis-group-role'; -export interface SanisGruppenzugehoerigkeitResponse { - rollen: SanisGroupRole[]; +export class SanisGruppenzugehoerigkeitResponse { + @IsOptional() + @IsArray() + @IsEnum(SanisGroupRole, { each: true }) + rollen?: SanisGroupRole[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts deleted file mode 100644 index ad5ac800cdf..00000000000 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-laufzeit-response.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface SanisLaufzeitResponse { - von: Date; - - bis: Date; -} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts index 1da6a4ad8f9..ab6dda48ece 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts @@ -1,5 +1,9 @@ -export interface SanisNameResponse { - familienname?: string; +import { IsString } from 'class-validator'; - vorname?: string; +export class SanisNameResponse { + @IsString() + familienname!: string; + + @IsString() + vorname!: string; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts index fa7d2846ad1..6b623cdbb3b 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts @@ -1,13 +1,20 @@ +import { Type } from 'class-transformer'; +import { IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SanisAnschriftResponse } from './sanis-anschrift-response'; -export interface SanisOrganisationResponse { - id: string; +export class SanisOrganisationResponse { + @IsString() + id!: string; - kennung: string; + @IsString() + kennung!: string; - name: string; - - typ: string; + @IsString() + name!: string; + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => SanisAnschriftResponse) anschrift?: SanisAnschriftResponse; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts index 6b225b58032..0e6dbcfb472 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts @@ -1,14 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsObject, IsOptional, ValidateNested } from 'class-validator'; import { SanisGeburtResponse } from './sanis-geburt-response'; import { SanisNameResponse } from './sanis-name-response'; -export interface SanisPersonResponse { - name?: SanisNameResponse; +export class SanisPersonResponse { + @IsObject() + @ValidateNested() + @Type(() => SanisNameResponse) + name!: SanisNameResponse; + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => SanisGeburtResponse) geburt?: SanisGeburtResponse; - - geschlecht?: string; - - lokalisierung?: string; - - vertrauensstufe?: string; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts index c7d1252b2da..6cf78aeb109 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts @@ -1,15 +1,25 @@ -import { SanisRole } from './sanis-role'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SanisGruppenResponse } from './sanis-gruppen-response'; import { SanisOrganisationResponse } from './sanis-organisation-response'; +import { SanisResponseValidationGroups } from './sanis-response-validation-groups'; +import { SanisRole } from './sanis-role'; -export interface SanisPersonenkontextResponse { - id: string; - - rolle: SanisRole; +export class SanisPersonenkontextResponse { + @IsString({ groups: [SanisResponseValidationGroups.USER, SanisResponseValidationGroups.GROUPS] }) + id!: string; - organisation: SanisOrganisationResponse; + @IsEnum(SanisRole, { groups: [SanisResponseValidationGroups.USER] }) + rolle!: SanisRole; - personenstatus: string; + @IsObject({ groups: [SanisResponseValidationGroups.SCHOOL] }) + @ValidateNested({ groups: [SanisResponseValidationGroups.SCHOOL] }) + @Type(() => SanisOrganisationResponse) + organisation!: SanisOrganisationResponse; + @IsOptional({ groups: [SanisResponseValidationGroups.GROUPS] }) + @IsArray({ groups: [SanisResponseValidationGroups.GROUPS] }) + @ValidateNested({ each: true, groups: [SanisResponseValidationGroups.GROUPS] }) + @Type(() => SanisGruppenResponse) gruppen?: SanisGruppenResponse[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-response-validation-groups.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-response-validation-groups.ts new file mode 100644 index 00000000000..9977965c752 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-response-validation-groups.ts @@ -0,0 +1,5 @@ +export enum SanisResponseValidationGroups { + USER = 'user', + SCHOOL = 'school', + GROUPS = 'groups', +} 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..68644ff3337 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 @@ -1,6 +1,12 @@ +import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; import { SanisGroupRole } from './sanis-group-role'; -export interface SanisSonstigeGruppenzugehoerigeResponse { - ktid: string; - rollen: SanisGroupRole[]; +export class SanisSonstigeGruppenzugehoerigeResponse { + @IsString() + ktid!: string; + + @IsOptional() + @IsArray() + @IsEnum(SanisGroupRole, { each: true }) + rollen?: SanisGroupRole[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts index fb8bb8ec6c8..193390df8bc 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts @@ -1,10 +1,21 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsObject, IsString, ValidateNested } from 'class-validator'; import { SanisPersonResponse } from './sanis-person-response'; import { SanisPersonenkontextResponse } from './sanis-personenkontext-response'; +import { SanisResponseValidationGroups } from './sanis-response-validation-groups'; -export interface SanisResponse { - pid: string; +export class SanisResponse { + @IsString({ groups: [SanisResponseValidationGroups.USER] }) + pid!: string; - person: SanisPersonResponse; + @IsObject({ groups: [SanisResponseValidationGroups.USER] }) + @ValidateNested({ groups: [SanisResponseValidationGroups.USER] }) + @Type(() => SanisPersonResponse) + person!: SanisPersonResponse; - personenkontexte: SanisPersonenkontextResponse[]; + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => SanisPersonenkontextResponse) + personenkontexte!: SanisPersonenkontextResponse[]; } 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 36e0b6a1d9f..14405d48c6d 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'; @@ -47,9 +48,6 @@ describe('SanisResponseMapper', () => { geburt: { datum: '2023-11-17', }, - geschlecht: 'x', - lokalisierung: 'de-de', - vertrauensstufe: '', }, personenkontexte: [ { @@ -58,24 +56,17 @@ describe('SanisResponseMapper', () => { organisation: { id: new UUID(externalSchoolId).toString(), name: 'schoolName', - typ: 'SCHULE', kennung: 'NI_123456_NI_ashd3838', anschrift: { ort: 'Hannover', }, }, - personenstatus: '', gruppen: [ { gruppe: { id: new UUID().toString(), bezeichnung: 'bezeichnung', typ: SanisGroupType.CLASS, - laufzeit: { - von: new Date(2023, 1, 8), - bis: new Date(2024, 7, 31), - }, - orgid: 'orgid', }, gruppenzugehoerigkeit: { rollen: [SanisGroupRole.TEACHER], @@ -159,32 +150,35 @@ 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 +193,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 +202,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,31 +212,74 @@ 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).toHaveLength(0); + }); + }); + + 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; + + return { + sanisResponse, + }; + }; + + it('should set other users to undefined', () => { const { sanisResponse } = setup(); const result: ExternalGroupDto[] | undefined = mapper.mapToExternalGroupDtos(sanisResponse); - expect(result![0].users).toHaveLength(0); + expect(result?.[0].otherUsers).toBeUndefined(); }); }); - describe('when a group has no other participants', () => { + describe('when other participants have unknown roles', () => { const setup = () => { const { sanisResponse } = setupSanisResponse(); - sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = undefined; + sanisResponse.personenkontexte[0].gruppen![0]!.sonstige_gruppenzugehoerige = [ + { + ktid: 'ktid', + rollen: [SanisGroupRole.SCHOOL_SUPPORT], + }, + ]; return { sanisResponse, }; }; - it('should return the group with only the user', () => { + 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 6d9ee9e721c..5a8f2f90b83 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 @@ -53,8 +53,8 @@ export class SanisResponseMapper { mapToExternalUserDto(source: SanisResponse): ExternalUserDto { const mapped = new ExternalUserDto({ - firstName: source.person.name?.vorname, - lastName: source.person.name?.familienname, + firstName: source.person.name.vorname, + lastName: source.person.name.familienname, roles: [this.mapSanisRoleToRoleName(source)], externalId: source.pid, birthday: source.person.geburt?.datum ? new Date(source.person.geburt?.datum) : undefined, @@ -67,51 +67,60 @@ export class SanisResponseMapper { return RoleMapping[source.personenkontexte[0].rolle]; } - mapToExternalGroupDtos(source: SanisResponse): ExternalGroupDto[] | undefined { - const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0]?.gruppen; + public mapToExternalGroupDtos(source: SanisResponse): ExternalGroupDto[] | undefined { + const groups: SanisGruppenResponse[] | undefined = source.personenkontexte[0].gruppen; if (!groups) { return undefined; } const mapped: ExternalGroupDto[] = groups - .map((group): 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 gruppenzugehoerigkeiten: ExternalGroupUserDto[] = sanisGroupUsers - .map((relation): ExternalGroupUserDto | null => this.mapToExternalGroupUser(relation)) - .filter((user): user is ExternalGroupUserDto => user !== null); - - const externalOrganizationId = source.personenkontexte[0].organisation?.id; - - 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, - }); - }) + .map((group) => this.mapExternalGroup(source, group)) .filter((group): group is ExternalGroupDto => group !== null); return mapped; } + private mapExternalGroup(source: SanisResponse, group: SanisGruppenResponse): ExternalGroupDto | null { + const groupType: GroupTypes | undefined = GroupTypeMapping[group.gruppe.typ]; + + if (!groupType) { + return null; + } + + const user: ExternalGroupUserDto | null = this.mapToExternalGroupUser({ + ktid: source.personenkontexte[0].id, + rollen: group.gruppenzugehoerigkeit.rollen, + }); + + if (!user) { + return null; + } + + 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, + externalId: group.gruppe.id, + user, + otherUsers, + }); + } + private mapToExternalGroupUser(relation: SanisSonstigeGruppenzugehoerigeResponse): ExternalGroupUserDto | null { - const userRole = GroupRoleMapping[relation.rollen[0]]; + if (!relation.rollen?.length) { + return null; + } + + const userRole: RoleName | undefined = GroupRoleMapping[relation.rollen[0]]; if (!userRole) { this.logger.info(new GroupRoleUnknownLoggable(relation)); 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 2a491f84dde..2341d46d889 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,15 +1,15 @@ -/* eslint-disable import/first */ -export * from '@shared/domain/entity/all-entities'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { GroupTypes } from '@modules/group'; +import { GroupTypes } from '@modules/group/domain'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ValidationErrorLoggableException } from '@shared/common/loggable-exception'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { axiosResponseFactory, setupEntities } from '@shared/testing'; +import { axiosResponseFactory } from '@shared/testing'; import { UUID } from 'bson'; +import * as classValidator from 'class-validator'; import { of } from 'rxjs'; import { ExternalGroupDto, @@ -20,9 +20,18 @@ import { ProvisioningSystemDto, } from '../../dto'; import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; -import { SanisGroupRole, SanisGroupType, SanisGruppenResponse, SanisResponse, SanisRole } from './response'; +import { + SanisGroupRole, + SanisGroupType, + SanisGruppenResponse, + SanisResponse, + SanisResponseValidationGroups, + SanisRole, +} from './response'; import { SanisResponseMapper } from './sanis-response.mapper'; import { SanisProvisioningStrategy } from './sanis.strategy'; +import ArgsType = jest.ArgsType; +import SpyInstance = jest.SpyInstance; const createAxiosResponse = (data: SanisResponse) => axiosResponseFactory.build({ @@ -36,9 +45,12 @@ describe('SanisStrategy', () => { let mapper: DeepMocked; let httpService: DeepMocked; - beforeAll(async () => { - await setupEntities(); + let validationFunction: SpyInstance< + ReturnType, + ArgsType + >; + beforeAll(async () => { module = await Test.createTestingModule({ providers: [ SanisProvisioningStrategy, @@ -60,6 +72,8 @@ describe('SanisStrategy', () => { strategy = module.get(SanisProvisioningStrategy); mapper = module.get(SanisResponseMapper); httpService = module.get(HttpService); + + validationFunction = jest.spyOn(classValidator, 'validate'); }); afterEach(() => { @@ -67,7 +81,7 @@ describe('SanisStrategy', () => { Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'true'); }); - const setupSanisResponse = () => { + const setupSanisResponse = (): SanisResponse => { return { pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', person: { @@ -75,9 +89,9 @@ describe('SanisStrategy', () => { vorname: 'Hans', familienname: 'Peter', }, - geschlecht: 'any', - lokalisierung: 'sn_ZW', - vertrauensstufe: '0', + geburt: { + datum: '2023-11-17', + }, }, personenkontexte: [ { @@ -86,21 +100,17 @@ describe('SanisStrategy', () => { organisation: { id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(), name: 'schoolName', - typ: 'SCHULE', kennung: 'Kennung', + anschrift: { + ort: 'Hannover', + }, }, - personenstatus: 'dead', gruppen: [ { gruppe: { id: new UUID().toString(), bezeichnung: 'bezeichnung', typ: SanisGroupType.CLASS, - laufzeit: { - von: new Date(2023, 1, 8), - bis: new Date(2024, 7, 31), - }, - orgid: 'orgid', }, gruppenzugehoerigkeit: { rollen: [SanisGroupRole.TEACHER], @@ -155,15 +165,10 @@ describe('SanisStrategy', () => { name: sanisGruppeResponse.gruppe.bezeichnung, externalId: sanisGruppeResponse.gruppe.id, type: GroupTypes.CLASS, - externalOrganizationId: sanisGruppeResponse.gruppe.orgid, - from: sanisGruppeResponse.gruppe.laufzeit.von, - until: sanisGruppeResponse.gruppe.laufzeit.bis, - users: [ - { - externalUserId: sanisResponse.personenkontexte[0].id, - roleName: RoleName.TEACHER, - }, - ], + user: { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, }), ]; @@ -171,6 +176,8 @@ describe('SanisStrategy', () => { mapper.mapToExternalUserDto.mockReturnValue(user); mapper.mapToExternalSchoolDto.mockReturnValue(school); mapper.mapToExternalGroupDtos.mockReturnValue(groups); + validationFunction.mockResolvedValueOnce([]); + validationFunction.mockResolvedValueOnce([]); return { input, @@ -178,6 +185,7 @@ describe('SanisStrategy', () => { user, school, groups, + sanisResponse, }; }; @@ -209,6 +217,30 @@ describe('SanisStrategy', () => { ); }); + it('should validate the response for user and school', async () => { + const { input, sanisResponse } = setup(); + + await strategy.getData(input); + + expect(validationFunction).toHaveBeenCalledWith(sanisResponse, { + always: true, + forbidUnknownValues: false, + groups: [SanisResponseValidationGroups.USER, SanisResponseValidationGroups.SCHOOL], + }); + }); + + it('should validate the response for groups', async () => { + const { input, sanisResponse } = setup(); + + await strategy.getData(input); + + expect(validationFunction).toHaveBeenCalledWith(sanisResponse, { + always: true, + forbidUnknownValues: false, + groups: [SanisResponseValidationGroups.GROUPS], + }); + }); + it('should return the oauth data', async () => { const { input, user, school, groups } = setup(); @@ -247,14 +279,27 @@ describe('SanisStrategy', () => { httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); mapper.mapToExternalUserDto.mockReturnValue(user); mapper.mapToExternalSchoolDto.mockReturnValue(school); + validationFunction.mockResolvedValueOnce([]); Configuration.set('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED', 'false'); return { input, + sanisResponse, }; }; + it('should not validate the response for groups', async () => { + const { input, sanisResponse } = setup(); + + await strategy.getData(input); + + expect(validationFunction).not.toHaveBeenCalledWith( + sanisResponse, + expect.objectContaining({ groups: [SanisResponseValidationGroups.GROUPS] }) + ); + }); + it('should not call mapToExternalGroupDtos', async () => { const { input } = setup(); @@ -289,15 +334,10 @@ describe('SanisStrategy', () => { name: sanisGruppeResponse.gruppe.bezeichnung, externalId: sanisGruppeResponse.gruppe.id, type: GroupTypes.CLASS, - externalOrganizationId: sanisGruppeResponse.gruppe.orgid, - from: sanisGruppeResponse.gruppe.laufzeit.von, - until: sanisGruppeResponse.gruppe.laufzeit.bis, - users: [ - { - externalUserId: sanisResponse.personenkontexte[0].id, - roleName: RoleName.TEACHER, - }, - ], + user: { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, }), ]; @@ -347,15 +387,10 @@ describe('SanisStrategy', () => { name: sanisGruppeResponse.gruppe.bezeichnung, externalId: sanisGruppeResponse.gruppe.id, type: GroupTypes.CLASS, - externalOrganizationId: sanisGruppeResponse.gruppe.orgid, - from: sanisGruppeResponse.gruppe.laufzeit.von, - until: sanisGruppeResponse.gruppe.laufzeit.bis, - users: [ - { - externalUserId: sanisResponse.personenkontexte[0].id, - roleName: RoleName.TEACHER, - }, - ], + user: { + externalUserId: sanisResponse.personenkontexte[0].id, + roleName: RoleName.TEACHER, + }, }), ]; @@ -363,6 +398,8 @@ describe('SanisStrategy', () => { mapper.mapToExternalUserDto.mockReturnValue(user); mapper.mapToExternalSchoolDto.mockReturnValue(school); mapper.mapToExternalGroupDtos.mockReturnValue(groups); + validationFunction.mockResolvedValueOnce([]); + validationFunction.mockResolvedValueOnce([]); return { input, @@ -377,5 +414,119 @@ describe('SanisStrategy', () => { expect(result.externalUser.roles).toEqual(expect.arrayContaining([RoleName.ADMINISTRATOR, RoleName.TEACHER])); }); }); + + describe('when the validation of the response fails', () => { + const setup = () => { + const provisioningUrl = 'sanisProvisioningUrl'; + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl, + }), + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse: SanisResponse = setupSanisResponse(); + const validationError: classValidator.ValidationError = new classValidator.ValidationError(); + + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse))); + validationFunction.mockResolvedValueOnce([validationError]); + + return { + input, + provisioningUrl, + sanisResponse, + }; + }; + + it('should throw a validation error', async () => { + const { input } = setup(); + + await expect(strategy.getData(input)).rejects.toThrow(ValidationErrorLoggableException); + }); + }); + + describe('when the response contains invalid empty objects', () => { + const setup = () => { + const provisioningUrl = 'sanisProvisioningUrl'; + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.SANIS, + provisioningUrl, + }), + idToken: 'sanisIdToken', + accessToken: 'sanisAccessToken', + }); + const sanisResponse = { + pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', + person: { + name: { + vorname: 'Hans', + familienname: 'Peter', + }, + geburt: { + datum: '2023-11-17', + }, + }, + personenkontexte: [ + { + id: new UUID().toString(), + rolle: SanisRole.LEIT, + organisation: { + id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(), + name: 'schoolName', + kennung: 'Kennung', + anschrift: { + ort: 'Hannover', + }, + }, + gruppen: [ + { + sonstige_gruppenzugehoerige: [{}], + }, + ], + }, + ], + }; + const user: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + }); + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + name: 'schoolName', + }); + + httpService.get.mockReturnValue(of(createAxiosResponse(sanisResponse as SanisResponse))); + mapper.mapToExternalUserDto.mockReturnValue(user); + mapper.mapToExternalSchoolDto.mockReturnValue(school); + mapper.mapToExternalGroupDtos.mockReturnValue(undefined); + validationFunction.mockRestore(); + validationFunction.mockRestore(); + + return { + input, + provisioningUrl, + user, + school, + sanisResponse, + }; + }; + + it('should not throw', async () => { + const { input } = setup(); + + await expect(strategy.getData(input)).resolves.not.toThrow(); + }); + + it('should return undefined for groups instead of an empty list', async () => { + const { input } = setup(); + + const result: OauthDataDto = await strategy.getData(input); + + expect(result.externalGroups).toBeUndefined(); + }); + }); }); }); 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 e57d46e937a..1475411890f 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts @@ -1,9 +1,12 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { 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'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { plainToClass } from 'class-transformer'; +import { validate, ValidationError } from 'class-validator'; import { firstValueFrom } from 'rxjs'; import { ExternalGroupDto, @@ -14,7 +17,7 @@ import { } from '../../dto'; import { OidcProvisioningStrategy } from '../oidc/oidc.strategy'; import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; -import { SanisResponse } from './response'; +import { SanisGruppenResponse, SanisResponse, SanisResponseValidationGroups } from './response'; import { SanisResponseMapper } from './sanis-response.mapper'; @Injectable() @@ -49,6 +52,15 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { this.httpService.get(input.system.provisioningUrl, axiosConfig) ); + const fixedData: SanisResponse = this.removeEmptyObjectsFromResponse(axiosResponse.data); + + const response: SanisResponse = plainToClass(SanisResponse, fixedData); + + await this.checkResponseValidation(response, [ + SanisResponseValidationGroups.USER, + SanisResponseValidationGroups.SCHOOL, + ]); + const externalUser: ExternalUserDto = this.responseMapper.mapToExternalUserDto(axiosResponse.data); this.addTeacherRoleIfAdmin(externalUser); @@ -56,6 +68,8 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { let externalGroups: ExternalGroupDto[] | undefined; if (Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED')) { + await this.checkResponseValidation(response, [SanisResponseValidationGroups.GROUPS]); + externalGroups = this.responseMapper.mapToExternalGroupDtos(axiosResponse.data); } @@ -69,6 +83,49 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { return oauthData; } + // This is a temporary fix to a problem with moin.schule and should be resolved after 12.12.23 + private removeEmptyObjectsFromResponse(response: SanisResponse): SanisResponse { + const fixedResponse: SanisResponse = { ...response }; + + if (fixedResponse?.personenkontexte?.length && fixedResponse.personenkontexte[0].gruppen) { + const groups: SanisGruppenResponse[] = fixedResponse.personenkontexte[0].gruppen; + + for (const group of groups) { + group.sonstige_gruppenzugehoerige = group.sonstige_gruppenzugehoerige?.filter( + (relation) => !this.isObjectEmpty(relation) + ); + + if (!group.sonstige_gruppenzugehoerige?.length) { + group.sonstige_gruppenzugehoerige = undefined; + } + } + + fixedResponse.personenkontexte[0].gruppen = groups.filter((group) => !this.isObjectEmpty(group)); + + if (!fixedResponse.personenkontexte[0].gruppen.length) { + fixedResponse.personenkontexte[0].gruppen = undefined; + } + } + + return fixedResponse; + } + + private isObjectEmpty(obj: unknown): boolean { + return typeof obj === 'object' && !!obj && !Object.keys(obj).some((key) => obj[key] !== undefined); + } + + private async checkResponseValidation(response: SanisResponse, groups: SanisResponseValidationGroups[]) { + const validationErrors: ValidationError[] = await validate(response, { + always: true, + forbidUnknownValues: false, + groups, + }); + + if (validationErrors.length) { + throw new ValidationErrorLoggableException(validationErrors); + } + } + private addTeacherRoleIfAdmin(externalUser: ExternalUserDto): void { if (externalUser.roles && externalUser.roles.includes(RoleName.ADMINISTRATOR)) { externalUser.roles.push(RoleName.TEACHER); diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index 55b14ca952a..b8b10c4a379 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -446,9 +446,6 @@ describe('UserLoginMigrationController (API)', () => { familienname: 'familienName', vorname: 'vorname', }, - geschlecht: 'weiblich', - lokalisierung: 'not necessary', - vertrauensstufe: 'not necessary', }, personenkontexte: [ { @@ -458,9 +455,7 @@ describe('UserLoginMigrationController (API)', () => { id: new UUID('aef1f4fd-c323-466e-962b-a84354c0e713').toString(), kennung: officialSchoolNumber, name: 'schulName', - typ: 'not necessary', }, - personenstatus: 'not necessary', }, ], }); diff --git a/apps/server/src/shared/common/loggable-exception/index.ts b/apps/server/src/shared/common/loggable-exception/index.ts index 51984c88bc6..680def0687f 100644 --- a/apps/server/src/shared/common/loggable-exception/index.ts +++ b/apps/server/src/shared/common/loggable-exception/index.ts @@ -1 +1,2 @@ export * from './not-found.loggable-exception'; +export * from './validation-error.loggable-exception'; diff --git a/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.spec.ts b/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.spec.ts new file mode 100644 index 00000000000..5c260b17c6c --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { ValidationError } from 'class-validator'; +import { ValidationErrorLoggableException } from './validation-error.loggable-exception'; + +describe('ValidationErrorLoggableException', () => { + describe('getLogMessage', () => { + const setup = () => { + const validationError: ValidationError = new ValidationError(); + + const exception = new ValidationErrorLoggableException([validationError]); + + return { + exception, + validationError, + }; + }; + + it('should log the correct message', () => { + const { exception, validationError } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'VALIDATION_ERROR', + stack: expect.any(String), + data: { + 0: validationError.toString(false, undefined, undefined, true), + }, + }); + }); + }); +}); diff --git a/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.ts new file mode 100644 index 00000000000..9f0ef32a1e8 --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/validation-error.loggable-exception.ts @@ -0,0 +1,32 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; +import { ValidationError } from 'class-validator'; + +export class ValidationErrorLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly validationErrors: ValidationError[]) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const validationErrorListObject: { [key: number]: string } = this.validationErrors.reduce( + (accumulator, currentValue, currentIndex) => { + return { + ...accumulator, + [currentIndex]: currentValue.toString(false, undefined, undefined, true), + }; + }, + {} + ); + + const message: ErrorLogMessage = { + type: 'VALIDATION_ERROR', + stack: this.stack, + data: { + ...validationErrorListObject, + }, + }; + + return message; + } +} 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 74bda384b18..d5eb865b89b 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 @@ -2,28 +2,24 @@ import { GroupTypes } from '@modules/group'; import { ExternalGroupDto } from '@modules/provisioning/dto'; import { RoleName } from '@shared/domain/interface'; import { ObjectId } from 'bson'; -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 f4d5e0050ce..c177ae70942 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';