Skip to content

Commit

Permalink
EW-1004 implement getData method for TSP Strategy (#5253)
Browse files Browse the repository at this point in the history
  • Loading branch information
MajedAlaitwniCap authored Sep 30, 2024
1 parent dded4fe commit 873e20c
Show file tree
Hide file tree
Showing 15 changed files with 316 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export class ExternalClassDto {
public readonly externalId: string;

public readonly name: string;
public readonly name?: string;

constructor(props: Readonly<ExternalClassDto>) {
this.externalId = props.externalId;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export class ExternalSchoolDto {
externalId: string;

name: string;
name?: string;

officialSchoolNumber?: string;

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/modules/provisioning/loggable/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './bad-data.loggable-exception';
export * from '../../loggable/bad-data.loggable-exception';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TspJwtPayload>) {
Object.assign(this, data);
}
}
113 changes: 111 additions & 2 deletions apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<OauthDataDto>({
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'));
});
});
});
Expand Down
Loading

0 comments on commit 873e20c

Please sign in to comment.