diff --git a/apps/server/src/modules/provisioning/dto/external-class.dto.ts b/apps/server/src/modules/provisioning/dto/external-class.dto.ts index a832922981c..a2641bd0707 100644 --- a/apps/server/src/modules/provisioning/dto/external-class.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-class.dto.ts @@ -1,7 +1,7 @@ export class ExternalClassDto { public readonly externalId: string; - public readonly name: string; + public readonly name?: string; constructor(props: Readonly) { this.externalId = props.externalId; diff --git a/apps/server/src/modules/provisioning/dto/external-school.dto.ts b/apps/server/src/modules/provisioning/dto/external-school.dto.ts index 701ee63f931..67aa2f15186 100644 --- a/apps/server/src/modules/provisioning/dto/external-school.dto.ts +++ b/apps/server/src/modules/provisioning/dto/external-school.dto.ts @@ -1,7 +1,7 @@ export class ExternalSchoolDto { externalId: string; - name: string; + name?: string; officialSchoolNumber?: string; diff --git a/apps/server/src/modules/provisioning/strategy/loggable/bad-data.loggable-exception.spec.ts b/apps/server/src/modules/provisioning/loggable/bad-data.loggable-exception.spec.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/loggable/bad-data.loggable-exception.spec.ts rename to apps/server/src/modules/provisioning/loggable/bad-data.loggable-exception.spec.ts diff --git a/apps/server/src/modules/provisioning/strategy/loggable/bad-data.loggable-exception.ts b/apps/server/src/modules/provisioning/loggable/bad-data.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/loggable/bad-data.loggable-exception.ts rename to apps/server/src/modules/provisioning/loggable/bad-data.loggable-exception.ts diff --git a/apps/server/src/modules/provisioning/loggable/index.ts b/apps/server/src/modules/provisioning/loggable/index.ts index ee2c20e74f0..9b9f2196e88 100644 --- a/apps/server/src/modules/provisioning/loggable/index.ts +++ b/apps/server/src/modules/provisioning/loggable/index.ts @@ -1,5 +1,7 @@ export * from './user-for-group-not-found.loggable'; export * from './school-for-group-not-found.loggable'; +export * from './bad-data.loggable-exception'; +export * from './school-name-required-loggable-exception'; export * from './group-role-unknown.loggable'; export { SchoolExternalToolCreatedLoggable } from './school-external-tool-created.loggable'; export { FetchingPoliciesInfoFailedLoggable } from './fetching-policies-info-failed.loggable'; diff --git a/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.spec.ts b/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.spec.ts new file mode 100644 index 00000000000..2eea53e1481 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.spec.ts @@ -0,0 +1,28 @@ +import { SchoolNameRequiredLoggableException } from './school-name-required-loggable-exception'; + +describe(SchoolNameRequiredLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const fieldName = 'id_token'; + + const exception = new SchoolNameRequiredLoggableException(fieldName); + + return { exception, fieldName }; + }; + + it('should return a LogMessage', () => { + const { exception, fieldName } = setup(); + + const logMessage = exception.getLogMessage(); + + expect(logMessage).toEqual({ + type: 'SCHOOL_NAME_REQUIRED', + message: 'External school name is required', + stack: exception.stack, + data: { + fieldName, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.ts b/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.ts new file mode 100644 index 00000000000..47986d51e65 --- /dev/null +++ b/apps/server/src/modules/provisioning/loggable/school-name-required-loggable-exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchoolNameRequiredLoggableException extends BusinessError implements Loggable { + constructor(private readonly fieldName: string) { + super( + { + type: 'SCHOOL_NAME_REQUIRED', + title: 'School name is required', + defaultMessage: 'External school name is required', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: this.type, + message: this.message, + stack: this.stack, + data: { + fieldName: this.fieldName, + }, + }; + } +} diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts index ecc7c932584..6746bb7bcd2 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.spec.ts @@ -13,7 +13,7 @@ import { SchoolService } from '@src/modules/school'; import { schoolFactory } from '@src/modules/school/testing'; import { UserService } from '@src/modules/user'; import { ExternalClassDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '../dto'; -import { BadDataLoggableException } from '../strategy/loggable'; +import { BadDataLoggableException } from '../loggable'; import { TspProvisioningService } from './tsp-provisioning.service'; describe('TspProvisioningService', () => { diff --git a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts index e9d2f6ec6b6..643e684dc40 100644 --- a/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/service/tsp-provisioning.service.ts @@ -8,7 +8,7 @@ import { RoleName } from '@shared/domain/interface'; import { School, SchoolService } from '@src/modules/school'; import { UserService } from '@src/modules/user'; import { ExternalClassDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '../dto'; -import { BadDataLoggableException } from '../strategy/loggable'; +import { BadDataLoggableException } from '../loggable'; @Injectable() export class TspProvisioningService { @@ -47,7 +47,9 @@ export class TspProvisioningService { if (currentClass) { // Case: Class exists -> update class currentClass.schoolId = school.id; - currentClass.name = clazz.name; + if (clazz.name) { + currentClass.name = clazz.name; + } currentClass.year = school.currentYear?.id; currentClass.source = this.ENTITY_SOURCE; currentClass.sourceOptions = new ClassSourceOptions({ tspUid: clazz.externalId }); diff --git a/apps/server/src/modules/provisioning/strategy/loggable/index.ts b/apps/server/src/modules/provisioning/strategy/loggable/index.ts index 213a83976d8..102d04f9628 100644 --- a/apps/server/src/modules/provisioning/strategy/loggable/index.ts +++ b/apps/server/src/modules/provisioning/strategy/loggable/index.ts @@ -1 +1 @@ -export * from './bad-data.loggable-exception'; +export * from '../../loggable/bad-data.loggable-exception'; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts index 31c41b8ee1a..42b2e72bb74 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts @@ -7,6 +7,7 @@ import { SchoolFeature } from '@shared/domain/types'; import { federalStateFactory, legacySchoolDoFactory, schoolYearFactory } from '@shared/testing'; import { ExternalSchoolDto } from '../../../dto'; import { SchulconnexSchoolProvisioningService } from './schulconnex-school-provisioning.service'; +import { SchoolNameRequiredLoggableException } from '../../../loggable'; describe(SchulconnexSchoolProvisioningService.name, () => { let module: TestingModule; @@ -146,6 +147,48 @@ describe(SchulconnexSchoolProvisioningService.name, () => { }); }); + describe('when the external system does not provide a Name for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should throw an error', async () => { + const { systemId, externalSchoolDto } = setup(); + + await expect(service.provisionExternalSchool(externalSchoolDto, systemId)).rejects.toThrowError( + new SchoolNameRequiredLoggableException('ExternalSchool.name') + ); + }); + }); + describe('when the external system does not provide a location for the school', () => { const setup = () => { const systemId = new ObjectId().toHexString(); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts index 4483b324b78..10aaaca1be0 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts @@ -5,6 +5,7 @@ import { LegacySchoolDo } from '@shared/domain/domainobject'; import { FederalStateEntity, SchoolYearEntity } from '@shared/domain/entity'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { ExternalSchoolDto } from '../../../dto'; +import { SchoolNameRequiredLoggableException } from '../../../loggable'; @Injectable() export class SchulconnexSchoolProvisioningService { @@ -53,6 +54,10 @@ export class SchulconnexSchoolProvisioningService { } private getSchoolName(externalSchool: ExternalSchoolDto): string { + if (!externalSchool.name) { + throw new SchoolNameRequiredLoggableException('ExternalSchool.name'); + } + const schoolName: string = externalSchool.location ? `${externalSchool.name} (${externalSchool.location})` : externalSchool.name; diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts new file mode 100644 index 00000000000..87df93adbce --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.jwt.payload.ts @@ -0,0 +1,35 @@ +import { IsString, IsOptional, IsArray } from 'class-validator'; +import { JwtPayload } from 'jsonwebtoken'; + +export class TspJwtPayload implements JwtPayload { + @IsString() + public sub!: string; + + @IsOptional() + @IsString() + public sid: string | undefined; + + @IsOptional() + @IsString() + public ptscListRolle: string | undefined; + + @IsOptional() + @IsString() + public personVorname: string | undefined; + + @IsOptional() + @IsString() + public personNachname: string | undefined; + + @IsOptional() + @IsString() + public ptscSchuleNummer: string | undefined; + + @IsOptional() + @IsArray() + public ptscListKlasseId: [] | undefined; + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts index 784296ea7c3..4ce648fe27d 100644 --- a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts @@ -5,7 +5,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { userDoFactory } from '@shared/testing'; +import { IdTokenExtractionFailureLoggableException } from '@src/modules/oauth/loggable'; +import jwt from 'jsonwebtoken'; import { + ExternalClassDto, ExternalSchoolDto, ExternalUserDto, OauthDataDto, @@ -55,8 +58,114 @@ describe('TspProvisioningStrategy', () => { describe('getData', () => { describe('When called', () => { - it('should throw', () => { - expect(() => sut.getData({} as OauthDataStrategyInputDto)).toThrow(); + const setup = () => { + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'externalSchoolId', + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + idToken: 'tspIdToken', + accessToken: 'tspAccessToken', + }); + + jest.spyOn(jwt, 'decode').mockImplementation(() => { + return { + sub: 'externalUserId', + sid: 'externalSchoolId', + ptscListRolle: 'schueler,lehrer,admin', + personVorname: 'firstName', + personNachname: 'lastName', + ptscSchuleNummer: 'externalSchoolId', + ptscListKlasseId: ['externalClassId1', 'externalClassId2'], + }; + }); + + const user: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + roles: [RoleName.STUDENT, RoleName.TEACHER, RoleName.ADMINISTRATOR], + firstName: 'firstName', + lastName: 'lastName', + }); + + const school: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalSchoolId', + }); + + const externalClass1 = new ExternalClassDto({ externalId: 'externalClassId1' }); + const externalClass2 = new ExternalClassDto({ externalId: 'externalClassId2' }); + const externalClasses = [externalClass1, externalClass2]; + + return { input, user, school, externalClasses }; + }; + + it('should return mapped oauthDataDto if input is valid', async () => { + const { input, user, school, externalClasses } = setup(); + const result = await sut.getData(input); + + expect(result).toEqual({ + system: input.system, + externalUser: user, + externalSchool: school, + externalGroups: undefined, + externalLicenses: undefined, + externalClasses, + }); + }); + }); + + describe('When idToken is invalid', () => { + const setup = () => { + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'externalSchoolId', + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + idToken: 'invalidIdToken', + accessToken: 'tspAccessToken', + }); + + jest.spyOn(jwt, 'decode').mockImplementation(() => null); + + return { input }; + }; + + it('should throw IdTokenExtractionFailure', async () => { + const { input } = setup(); + + await expect(sut.getData(input)).rejects.toThrow(new IdTokenExtractionFailureLoggableException('sub')); + }); + }); + + describe('When payload is invalid', () => { + const setup = () => { + const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({ + system: new ProvisioningSystemDto({ + systemId: 'externalSchoolId', + provisioningStrategy: SystemProvisioningStrategy.TSP, + }), + idToken: 'tspIdToken', + accessToken: 'tspAccessToken', + }); + + jest.spyOn(jwt, 'decode').mockImplementation(() => { + return { + sub: 'externalUserId', + sid: 1000, + ptscListRolle: 'teacher', + personVorname: 'firstName', + personNachname: 'lastName', + ptscSchuleNummer: 'externalSchoolId', + ptscListKlasseId: ['externalClassId1', 'externalClassId2'], + }; + }); + + return { input }; + }; + + it('should throw IdTokenExtractionFailure', async () => { + const { input } = setup(); + + await expect(sut.getData(input)).rejects.toThrow(new IdTokenExtractionFailureLoggableException('sub')); }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts index 8f05262f977..3ed54b80762 100644 --- a/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts @@ -1,12 +1,30 @@ -import { Injectable, NotImplementedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { RoleName } from '@shared/domain/interface'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto } from '../../dto'; +import { IdTokenExtractionFailureLoggableException } from '@src/modules/oauth/loggable'; +import { validate } from 'class-validator'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import { + ExternalClassDto, + ExternalSchoolDto, + ExternalUserDto, + OauthDataDto, + OauthDataStrategyInputDto, + ProvisioningDto, +} from '../../dto'; import { TspProvisioningService } from '../../service/tsp-provisioning.service'; import { ProvisioningStrategy } from '../base.strategy'; import { BadDataLoggableException } from '../loggable'; +import { TspJwtPayload } from './tsp.jwt.payload'; @Injectable() export class TspProvisioningStrategy extends ProvisioningStrategy { + RoleMapping: Record = { + lehrer: RoleName.TEACHER, + schueler: RoleName.STUDENT, + admin: RoleName.ADMINISTRATOR, + }; + constructor(private readonly provisioningService: TspProvisioningService) { super(); } @@ -15,10 +33,43 @@ export class TspProvisioningStrategy extends ProvisioningStrategy { return SystemProvisioningStrategy.TSP; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - override getData(input: OauthDataStrategyInputDto): Promise { - // TODO EW-1004 - throw new NotImplementedException(); + override async getData(input: OauthDataStrategyInputDto): Promise { + const decodedAccessToken: JwtPayload | null = jwt.decode(input.accessToken, { json: true }); + + if (!decodedAccessToken) { + throw new IdTokenExtractionFailureLoggableException('sub'); + } + + const payload = new TspJwtPayload(decodedAccessToken); + const errors = await validate(payload); + + if (errors.length > 0) { + throw new IdTokenExtractionFailureLoggableException(errors.map((error) => error.property).join(', ')); + } + + const externalUserDto = new ExternalUserDto({ + externalId: payload.sub, + firstName: payload.personVorname, + lastName: payload.personNachname, + roles: (payload.ptscListRolle ?? '').split(',').map((tspRole) => this.RoleMapping[tspRole]), + }); + + const externalSchoolDto = new ExternalSchoolDto({ + externalId: payload.ptscSchuleNummer || '', + }); + + const externalClassDtoList = (payload.ptscListKlasseId ?? []).map( + (classId: string) => new ExternalClassDto({ externalId: classId }) + ); + + const oauthDataDto = new OauthDataDto({ + system: input.system, + externalUser: externalUserDto, + externalSchool: externalSchoolDto, + externalClasses: externalClassDtoList, + }); + + return oauthDataDto; } override async apply(data: OauthDataDto): Promise {