diff --git a/charts/dbildungs-iam-server/config/config.json b/charts/dbildungs-iam-server/config/config.json index 7c79128f3..8f22d4b63 100644 --- a/charts/dbildungs-iam-server/config/config.json +++ b/charts/dbildungs-iam-server/config/config.json @@ -26,11 +26,11 @@ "USERNAME": "default", "USE_TLS": false }, - "LDAP": { - "URL": "ldap://dbildungs-iam-server-ldap", - "BIND_DN": "cn=admin,dc=schule-sh,dc=de", - "PASSWORD": "admin" - }, + "LDAP": { + "URL": "ldap://dbildungs-iam-server-ldap", + "BIND_DN": "cn=admin,dc=schule-sh,dc=de", + "PASSWORD": "admin" + }, "DATA": { "ROOT_ORGANISATION_ID": "d39cb7cf-2f9b-45f1-849f-973661f2f057" }, @@ -45,5 +45,11 @@ "KEYCLOAK_ADMINISTRATION_MODULE_LOG_LEVEL": "debug", "HEALTH_MODULE_LOG_LEVEL": "debug", "BACKEND_FOR_FRONTEND_MODULE_LOG_LEVEL": "debug" + }, + "ITSLEARNING": { + "ENABLED": false, + "ENDPOINT": "https://itslearning.example.com", + "USERNAME": "username", + "PASSWORD": "password" } } diff --git a/charts/dbildungs-iam-server/templates/_dbildungs-iam-server-envs.tpl b/charts/dbildungs-iam-server/templates/_dbildungs-iam-server-envs.tpl index 52a5eb391..b1515f78e 100644 --- a/charts/dbildungs-iam-server/templates/_dbildungs-iam-server-envs.tpl +++ b/charts/dbildungs-iam-server/templates/_dbildungs-iam-server-envs.tpl @@ -26,4 +26,19 @@ secretKeyRef: name: {{ default .Values.auth.existingSecret .Values.auth.secretName }} key: frontend-sessionSecret + - name: ITSLEARNING_ENDPOINT + valueFrom: + secretKeyRef: + name: {{ default .Values.auth.existingSecret .Values.auth.secretName }} + key: itslearning-endpoint + - name: ITSLEARNING_USERNAME + valueFrom: + secretKeyRef: + name: {{ default .Values.auth.existingSecret .Values.auth.secretName }} + key: itslearning-username + - name: ITSLEARNING_PASSWORD + valueFrom: + secretKeyRef: + name: {{ default .Values.auth.existingSecret .Values.auth.secretName }} + key: itslearning-password {{- end}} \ No newline at end of file diff --git a/charts/dbildungs-iam-server/templates/secret.yaml b/charts/dbildungs-iam-server/templates/secret.yaml index 31dd7728e..680d14478 100644 --- a/charts/dbildungs-iam-server/templates/secret.yaml +++ b/charts/dbildungs-iam-server/templates/secret.yaml @@ -12,5 +12,8 @@ data: db-username: {{ .Values.database.username }} keycloak-adminSecret: {{ .Values.auth.keycloak_adminSecret }} keycloak-clientSecret: {{ .Values.auth.keycloak_clientSecret }} + itslearning-endpoint: {{ .Values.auth.itslearning_endpoint }} + itslearning-username: {{ .Values.auth.itslearning_username }} + itslearning-password: {{ .Values.auth.itslearning_password }} secrets-json: {{ .Values.auth.secrets_json }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/dbildungs-iam-server/values.yaml b/charts/dbildungs-iam-server/values.yaml index 71b6b6ee1..23c57ebda 100644 --- a/charts/dbildungs-iam-server/values.yaml +++ b/charts/dbildungs-iam-server/values.yaml @@ -34,6 +34,9 @@ auth: keycloak_clientSecret: "" secrets_json: "" frontend_sessionSecret: "" + itslearning_endpoint: "" + itslearning_username: "" + itslearning_password: "" backend: diff --git a/config/config.json b/config/config.json index d519a504f..57eaabee5 100644 --- a/config/config.json +++ b/config/config.json @@ -53,5 +53,11 @@ "KEYCLOAK_ADMINISTRATION_MODULE_LOG_LEVEL": "debug", "HEALTH_MODULE_LOG_LEVEL": "debug", "BACKEND_FOR_FRONTEND_MODULE_LOG_LEVEL": "debug" + }, + "ITSLEARNING": { + "ENABLED": false, + "ENDPOINT": "https://itslearning-test.example.com", + "USERNAME": "username", + "PASSWORD": "password" } } diff --git a/package-lock.json b/package-lock.json index 74d066579..2704ba6ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "connect-redis": "^7.1.1", "express": "^4.19.2", "express-session": "^1.18.0", + "fast-xml-parser": "^4.4.0", "follow-redirects": "^1.15.6", "generate-password-ts": "^1.6.5", "jsonwebtoken": "^9.0.2", @@ -6670,6 +6671,27 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-xml-parser": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", + "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify-plugin": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", @@ -11557,6 +11579,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", diff --git a/package.json b/package.json index f543f296b..214ddfbce 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "connect-redis": "^7.1.1", "express": "^4.19.2", "express-session": "^1.18.0", + "fast-xml-parser": "^4.4.0", "follow-redirects": "^1.15.6", "generate-password-ts": "^1.6.5", "jsonwebtoken": "^9.0.2", diff --git a/src/modules/itslearning/actions/base-action.spec.ts b/src/modules/itslearning/actions/base-action.spec.ts new file mode 100644 index 000000000..fbaaf0e43 --- /dev/null +++ b/src/modules/itslearning/actions/base-action.spec.ts @@ -0,0 +1,84 @@ +import { DomainError } from '../../../shared/error/domain.error.js'; +import { ItsLearningError } from '../../../shared/error/its-learning.error.js'; +import { IMSESAction } from './base-action.js'; +import { faker } from '@faker-js/faker'; + +function buildXMLResponse(codeMajor: 'success' | 'failure', severity: 'status' | 'error', body: string): string { + return ` + + + + + ${codeMajor} + ${severity}c + + + + + + ${faker.date.recent().toISOString()} + ${faker.date.soon().toISOString()} + + + + + ${body} + +`; +} + +type DummyResponse = { + dummyResponse: string; +}; + +class TestAction extends IMSESAction { + public action: string = faker.internet.url(); + + public buildRequest(): object { + return {}; + } + + public parseBody(body: DummyResponse): Result { + return { + ok: true, + value: body.dummyResponse, + }; + } +} + +describe('IMSESAction', () => { + describe('parseResponse', () => { + it('should parse XML', () => { + const xmlTest: string = buildXMLResponse('success', 'status', 'test'); + const testAction: TestAction = new TestAction(); + + const result: Result = testAction.parseResponse(xmlTest); + + expect(result).toEqual({ + ok: true, + value: 'test', + }); + }); + + it('should return ItsLearningError if response is an error', () => { + const xmlTest: string = buildXMLResponse('failure', 'error', ''); + const testAction: TestAction = new TestAction(); + + const result: Result = testAction.parseResponse(xmlTest); + + expect(result).toEqual({ + ok: false, + error: new ItsLearningError('Request failed', expect.anything() as Record), + }); + }); + }); +}); diff --git a/src/modules/itslearning/actions/base-action.ts b/src/modules/itslearning/actions/base-action.ts new file mode 100644 index 000000000..bbf4db695 --- /dev/null +++ b/src/modules/itslearning/actions/base-action.ts @@ -0,0 +1,64 @@ +import { XMLBuilder, XMLParser } from 'fast-xml-parser'; + +import { DomainError, ItsLearningError } from '../../../shared/error/index.js'; + +export type StatusInfo = + | { + codeMajor: 'failure'; + severity: 'error'; + } + | { + codeMajor: 'success'; + severity: 'status'; + }; + +export type BaseResponse = { + Envelope: { + Header: { + syncResponseHeaderInfo: { + statusInfo: StatusInfo; + }; + }; + + Body: BodyResponse; + }; +}; + +export abstract class IMSESAction { + protected readonly xmlBuilder: XMLBuilder = new XMLBuilder({ ignoreAttributes: false }); + + protected readonly xmlParser: XMLParser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + isArray: (tagName: string, jPath: string, isLeafNode: boolean, isAttribute: boolean) => + this.isArrayOverride(tagName, jPath, isLeafNode, isAttribute), + }); + + public abstract action: string; + + public abstract buildRequest(): object; + + // Customize parsing behaviour, see X2jOptions.isArray + public isArrayOverride(_tagName: string, _jPath: string, _isLeafNode: boolean, _isAttribute: boolean): boolean { + return false; + } + + /** + * Will be called if the response was successful + * @param body The contents of the response body + */ + public abstract parseBody(body: ResponseBodyType): Result; + + public parseResponse(input: string): Result { + const result: BaseResponse = this.xmlParser.parse(input) as BaseResponse; + + if (result.Envelope.Header.syncResponseHeaderInfo.statusInfo.codeMajor === 'failure') { + return { + ok: false, + error: new ItsLearningError('Request failed', result), + }; + } else { + return this.parseBody(result.Envelope.Body); + } + } +} diff --git a/src/modules/itslearning/actions/create-group.action.spec.ts b/src/modules/itslearning/actions/create-group.action.spec.ts new file mode 100644 index 000000000..f2c10733e --- /dev/null +++ b/src/modules/itslearning/actions/create-group.action.spec.ts @@ -0,0 +1,33 @@ +import { faker } from '@faker-js/faker'; +import { CreateGroupAction } from './create-group.action.js'; + +describe('CreateGroupAction', () => { + describe('buildRequest', () => { + it('should return object', () => { + const action: CreateGroupAction = new CreateGroupAction({ + id: faker.string.uuid(), + name: `${faker.word.adjective()} school`, + parentId: faker.string.uuid(), + type: 'School', + }); + + expect(action.buildRequest()).toBeDefined(); + }); + }); + + describe('parseBody', () => { + it('should void result', () => { + const action: CreateGroupAction = new CreateGroupAction({ + id: faker.string.uuid(), + name: `${faker.word.adjective()} school`, + parentId: faker.string.uuid(), + type: 'School', + }); + + expect(action.parseBody()).toEqual({ + ok: true, + value: undefined, + }); + }); + }); +}); diff --git a/src/modules/itslearning/actions/create-group.action.ts b/src/modules/itslearning/actions/create-group.action.ts new file mode 100644 index 000000000..b3b8404aa --- /dev/null +++ b/src/modules/itslearning/actions/create-group.action.ts @@ -0,0 +1,72 @@ +import { DomainError } from '../../../shared/error/domain.error.js'; +import { IMS_COMMON_SCHEMA, IMS_GROUP_MAN_DATA_SCHEMA, IMS_GROUP_MAN_MESS_SCHEMA } from '../schemas.js'; +import { IMSESAction } from './base-action.js'; + +// Incomplete +export type CreateGroupParams = { + id: string; + + name: string; + type: 'School' | 'Course' | 'CourseGroup'; + + parentId: string; + relationLabel?: string; + + longDescription?: string; + fullDescription?: string; +}; + +type CreateGroupResponseBody = { + createGroupResponse: undefined; +}; + +export class CreateGroupAction extends IMSESAction { + public override action: string = 'http://www.imsglobal.org/soap/gms/createGroup'; + + public constructor(private readonly params: CreateGroupParams) { + super(); + } + + public override buildRequest(): object { + return { + 'ims:createGroupRequest': { + '@_xmlns:ims': IMS_GROUP_MAN_MESS_SCHEMA, + '@_xmlns:ims1': IMS_COMMON_SCHEMA, + '@_xmlns:ims2': IMS_GROUP_MAN_DATA_SCHEMA, + + 'ims:sourcedId': { + 'ims1:identifier': this.params.id, + }, + + 'ims:group': { + 'ims2:groupType': { + 'ims2:scheme': 'ItslearningOrganisationTypes', + 'ims2:typeValue': { + 'ims2:type': this.params.type, + }, + }, + 'ims2:relationship': { + 'ims2:relation': 'Parent', + 'ims2:sourceId': { + 'ims1:identifier': this.params.parentId, + }, + 'ims2:label': this.params.relationLabel, + }, + 'ims2:description': { + 'ims2:descShort': this.params.name, + 'ims2:descLong': this.params.longDescription, + 'ims2:descFull': this.params.fullDescription, + }, + }, + }, + }; + } + + public override parseBody(): Result { + // Response does not contain data + return { + ok: true, + value: undefined, + }; + } +} diff --git a/src/modules/itslearning/actions/read-all-persons.action.spec.ts b/src/modules/itslearning/actions/read-all-persons.action.spec.ts new file mode 100644 index 000000000..b5cd56713 --- /dev/null +++ b/src/modules/itslearning/actions/read-all-persons.action.spec.ts @@ -0,0 +1,82 @@ +import { faker } from '@faker-js/faker'; +import { ReadAllPersonsAction } from './read-all-persons.action.js'; + +describe('ReadAllPersonsAction', () => { + describe('buildRequest', () => { + it('should return object', () => { + const action: ReadAllPersonsAction = new ReadAllPersonsAction({ + pageIndex: 1, + pageSize: 10, + }); + + expect(action.buildRequest()).toBeDefined(); + }); + }); + + describe('isArrayOverride', () => { + it('should return true for tag "personIdPair"', () => { + const action: ReadAllPersonsAction = new ReadAllPersonsAction({ + pageIndex: 1, + pageSize: 10, + }); + + expect(action.isArrayOverride('personIdPair')).toBe(true); + }); + + it('should return true for tag "partName"', () => { + const action: ReadAllPersonsAction = new ReadAllPersonsAction({ + pageIndex: 1, + pageSize: 10, + }); + + expect(action.isArrayOverride('partName')).toBe(true); + }); + + it('should return true for tag "tel"', () => { + const action: ReadAllPersonsAction = new ReadAllPersonsAction({ + pageIndex: 1, + pageSize: 10, + }); + + expect(action.isArrayOverride('tel')).toBe(true); + }); + }); + + describe('parseBody', () => { + it('should void result', () => { + const action: ReadAllPersonsAction = new ReadAllPersonsAction({ + pageIndex: 1, + pageSize: 10, + }); + const personId: string = faker.string.uuid(); + const userId: string = faker.internet.userName(); + + expect( + action.parseBody({ + readAllPersonsResponse: { + virtualCount: 1, + personIdPairSet: { + personIdPair: [ + { + sourceId: { identifier: personId }, + person: { + userId: { + userIdValue: userId, + }, + }, + }, + ], + }, + }, + }), + ).toEqual({ + ok: true, + value: [ + { + id: personId, + }, + ], + }); + }); + }); +}); diff --git a/src/modules/itslearning/actions/read-all-persons.action.ts b/src/modules/itslearning/actions/read-all-persons.action.ts new file mode 100644 index 000000000..0f93d1468 --- /dev/null +++ b/src/modules/itslearning/actions/read-all-persons.action.ts @@ -0,0 +1,81 @@ +import { DomainError } from '../../../shared/error/domain.error.js'; + +import { IMS_PERSON_MAN_MESS_SCHEMA } from '../schemas.js'; +import { IMSESAction } from './base-action.js'; + +export type ReadAllPersonsParams = { + pageIndex: number; + pageSize: number; + createdFrom?: Date; + onlyManuallyCreatedUsers?: boolean; + convertFromManual?: boolean; +}; + +export type PersonResponse = { + id: string; +}; + +// Incomplete +type PersonIdPair = { + sourceId: { + identifier: string; + }; + person: { + userId: { + userIdValue: string; + }; + }; +}; + +// Incomplete +type ReadAllPersonsReponseBody = { + readAllPersonsResponse: { + personIdPairSet: { + personIdPair: PersonIdPair[]; + }; + virtualCount: number; + }; +}; + +function mapPersonIdPairToPersonResponse(idPair: PersonIdPair): PersonResponse { + return { + id: idPair.sourceId.identifier, + }; +} + +export class ReadAllPersonsAction extends IMSESAction { + public override action: string = 'http://www.imsglobal.org/soap/pms/readAllPersons'; + + public constructor(private readonly params: ReadAllPersonsParams) { + super(); + } + + public override buildRequest(): object { + return { + 'ims:readAllPersonsRequest': { + '@_xmlns:ims': IMS_PERSON_MAN_MESS_SCHEMA, + + 'ims:PageIndex': this.params.pageIndex, + 'ims:PageSize': this.params.pageSize, + 'ims:CreatedFrom': this.params.createdFrom?.toISOString(), + 'ims:OnlyManuallyCreatedUsers': this.params.onlyManuallyCreatedUsers, + 'ims:ConvertFromManual': this.params.convertFromManual, + }, + }; + } + + public override isArrayOverride(tagName: string): boolean { + return ['personIdPair', 'partName', 'tel'].includes(tagName); + } + + public override parseBody(body: ReadAllPersonsReponseBody): Result { + const persons: PersonResponse[] = body.readAllPersonsResponse.personIdPairSet.personIdPair.map( + mapPersonIdPairToPersonResponse, + ); + + return { + ok: true, + value: persons, + }; + } +} diff --git a/src/modules/itslearning/itslearning.module.spec.ts b/src/modules/itslearning/itslearning.module.spec.ts new file mode 100644 index 000000000..242d01500 --- /dev/null +++ b/src/modules/itslearning/itslearning.module.spec.ts @@ -0,0 +1,29 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ConfigTestModule } from '../../../test/utils/index.js'; +import { ItsLearningModule } from './itslearning.module.js'; +import { ItsLearningIMSESService } from './itslearning.service.js'; + +describe('ItsLearningModule', () => { + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule, ItsLearningModule], + }).compile(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(module).toBeDefined(); + }); + + describe('when module is initialized', () => { + it('should resolve ItsLearningIMSESService', () => { + expect(module.get(ItsLearningIMSESService)).toBeInstanceOf(ItsLearningIMSESService); + }); + }); +}); diff --git a/src/modules/itslearning/itslearning.module.ts b/src/modules/itslearning/itslearning.module.ts new file mode 100644 index 000000000..593642adf --- /dev/null +++ b/src/modules/itslearning/itslearning.module.ts @@ -0,0 +1,12 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; + +import { LoggerModule } from '../../core/logging/logger.module.js'; +import { ItsLearningIMSESService } from './itslearning.service.js'; + +@Module({ + imports: [LoggerModule.register(ItsLearningModule.name), HttpModule], + providers: [ItsLearningIMSESService], + exports: [ItsLearningIMSESService], +}) +export class ItsLearningModule {} diff --git a/src/modules/itslearning/itslearning.service.spec.ts b/src/modules/itslearning/itslearning.service.spec.ts new file mode 100644 index 000000000..f0b49b227 --- /dev/null +++ b/src/modules/itslearning/itslearning.service.spec.ts @@ -0,0 +1,90 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; +import { of, throwError } from 'rxjs'; +import { ConfigTestModule } from '../../../test/utils/index.js'; +import { DomainError, ItsLearningError } from '../../shared/error/index.js'; +import { IMSESAction } from './actions/base-action.js'; +import { ItsLearningIMSESService } from './itslearning.service.js'; + +describe('ItsLearningIMSESService', () => { + let module: TestingModule; + let sut: ItsLearningIMSESService; + + let httpServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule], + providers: [ + ItsLearningIMSESService, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(ItsLearningIMSESService); + httpServiceMock = module.get(HttpService); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('send', () => { + it('should call HttpService.post', async () => { + const mockAction: DeepMocked> = createMock>(); + mockAction.buildRequest.mockReturnValueOnce({}); + mockAction.action = 'testAction'; + httpServiceMock.post.mockReturnValueOnce(of({} as AxiosResponse)); + + await sut.send(mockAction); + + expect(httpServiceMock.post).toHaveBeenCalledWith( + 'https://itslearning-test.example.com', + expect.stringContaining('username'), + { + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + SOAPAction: `"testAction"`, + }, + }, + ); + }); + + it('should call parseResponse of action and return result', async () => { + const mockAction: DeepMocked> = createMock>(); + mockAction.buildRequest.mockReturnValueOnce({}); + mockAction.parseResponse.mockReturnValueOnce({ ok: true, value: 'TestResult' }); + mockAction.action = 'testAction'; + httpServiceMock.post.mockReturnValueOnce(of({} as AxiosResponse)); + + const result: Result = await sut.send(mockAction); + + expect(result).toEqual({ + ok: true, + value: 'TestResult', + }); + }); + + it('should return ItsLearningError if request failed', async () => { + const error: Error = new Error('AxiosError'); + const mockAction: DeepMocked> = createMock>(); + httpServiceMock.post.mockReturnValueOnce(throwError(() => error)); + + const result: Result = await sut.send(mockAction); + + expect(result).toEqual({ + ok: false, + error: new ItsLearningError('Request failed', [error]), + }); + }); + }); +}); diff --git a/src/modules/itslearning/itslearning.service.ts b/src/modules/itslearning/itslearning.service.ts new file mode 100644 index 000000000..0ebb7d6d2 --- /dev/null +++ b/src/modules/itslearning/itslearning.service.ts @@ -0,0 +1,101 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AxiosResponse } from 'axios'; +import { Hash, createHash } from 'crypto'; +import { XMLBuilder } from 'fast-xml-parser'; +import { lastValueFrom } from 'rxjs'; + +import { ItsLearningConfig, ServerConfig } from '../../shared/config/index.js'; +import { DomainError, ItsLearningError } from '../../shared/error/index.js'; +import { IMSESAction } from './actions/base-action.js'; + +@Injectable() +export class ItsLearningIMSESService { + private readonly endpoint: string; + + private readonly username: string; + + private readonly password: string; + + private readonly xmlBuilder: XMLBuilder = new XMLBuilder({ ignoreAttributes: false }); + + public constructor( + private readonly httpService: HttpService, + configService: ConfigService, + ) { + const itsLearningConfig: ItsLearningConfig = configService.getOrThrow('ITSLEARNING'); + + this.endpoint = itsLearningConfig.ENDPOINT; + this.username = itsLearningConfig.USERNAME; + this.password = itsLearningConfig.PASSWORD; + } + + public async send( + action: IMSESAction, + ): Promise> { + const body: object = action.buildRequest(); + const message: string = this.createMessage(body); + + try { + const response: AxiosResponse = await lastValueFrom( + this.httpService.post(this.endpoint, message, { + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + SOAPAction: `"${action.action}"`, + }, + }), + ); + + return action.parseResponse(response.data); + } catch (err: unknown) { + return { + ok: false, + error: new ItsLearningError('Request failed', [err]), + }; + } + } + + private createMessage(body: object): string { + return this.xmlBuilder.build({ + 'soapenv:Envelope': { + '@_xmlns:soapenv': 'http://schemas.xmlsoap.org/soap/envelope/', + + 'soapenv:Header': this.createSecurityObject(), + + 'soapenv:Body': body, + }, + }) as string; + } + + private createSecurityObject(): object { + const now: string = new Date().toISOString(); + const nHash: Hash = createHash('sha1'); + nHash.update(now + Math.random()); + const nonce: string = nHash.digest('base64'); + + return { + 'wsse:Security': { + '@_xmlns:wsse': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', + '@_xmlns:wsu': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', + '@_soapenv:mustUnderstand': 1, + + 'wsse:UsernameToken': { + 'wsse:Username': this.username, + 'wsse:Password': { + '@_Type': + 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText', + + '#text': this.password, + }, + 'wsse:Nonce': { + '@_EncodingType': + 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary', + '#text': nonce, + }, + 'wsu:Created': now, + }, + }, + }; + } +} diff --git a/src/modules/itslearning/schemas.ts b/src/modules/itslearning/schemas.ts new file mode 100644 index 000000000..e088ec76d --- /dev/null +++ b/src/modules/itslearning/schemas.ts @@ -0,0 +1,8 @@ +export const IMS_PERSON_MAN_MESS_SCHEMA: string = + 'http://www.imsglobal.org/services/pms/xsd/imsPersonManMessSchema_v1p0'; + +export const IMS_GROUP_MAN_MESS_SCHEMA: string = 'http://www.imsglobal.org/services/gms/xsd/imsGroupManMessSchema_v1p0'; + +export const IMS_COMMON_SCHEMA: string = 'http://www.imsglobal.org/services/common/imsCommonSchema_v1p0'; + +export const IMS_GROUP_MAN_DATA_SCHEMA: string = 'http://www.imsglobal.org/services/gms/xsd/imsGroupManDataSchema_v1p0'; diff --git a/src/server/server.module.ts b/src/server/server.module.ts index 91778d58b..06f6e0e95 100644 --- a/src/server/server.module.ts +++ b/src/server/server.module.ts @@ -29,6 +29,7 @@ import { AccessGuard } from '../modules/authentication/api/access.guard.js'; import { PermissionsInterceptor } from '../modules/authentication/services/permissions.interceptor.js'; import { PassportModule } from '@nestjs/passport'; import { EventModule } from '../core/eventbus/index.js'; +import { ItsLearningModule } from '../modules/itslearning/itslearning.module.js'; import { LdapModule } from '../core/ldap/ldap.module.js'; @Module({ @@ -80,6 +81,7 @@ import { LdapModule } from '../core/ldap/ldap.module.js'; PersonenKontextApiModule, ErrorModule, KeycloakConfigModule, + ItsLearningModule, LdapModule, ], providers: [ diff --git a/src/shared/config/config.env.ts b/src/shared/config/config.env.ts index 22a661907..c9736453b 100644 --- a/src/shared/config/config.env.ts +++ b/src/shared/config/config.env.ts @@ -2,12 +2,14 @@ import { DbConfig } from './db.config.js'; import { KeycloakConfig } from './keycloak.config.js'; import { FrontendConfig } from './frontend.config.js'; import { HostConfig } from './host.config.js'; +import { ItsLearningConfig } from './itslearning.config.js'; export default (): { DB: Partial; KEYCLOAK: Partial; FRONTEND: Partial; HOST: Partial; + ITSLEARNING: Partial; } => ({ DB: { DB_NAME: process.env['DB_NAME'], @@ -28,4 +30,10 @@ export default (): { HOST: { HOSTNAME: process.env['BACKEND_HOSTNAME'], }, + ITSLEARNING: { + ENABLED: process.env['ITSLEARNING_ENABLED']?.toLowerCase() === 'true', + ENDPOINT: process.env['ITSLEARNING_ENDPOINT'], + USERNAME: process.env['ITSLEARNING_USERNAME'], + PASSWORD: process.env['ITSLEARNING_PASSWORD'], + }, }); diff --git a/src/shared/config/config.loader.spec.ts b/src/shared/config/config.loader.spec.ts index 8375319f3..9dbac4c29 100644 --- a/src/shared/config/config.loader.spec.ts +++ b/src/shared/config/config.loader.spec.ts @@ -45,6 +45,11 @@ describe('configloader', () => { LOGGING: { DEFAULT_LOG_LEVEL: 'debug', }, + ITSLEARNING: { + ENABLED: true, + ENDPOINT: 'http://itslearning', + USERNAME: 'username', + }, }; const secrets: DeepPartial = { @@ -52,6 +57,9 @@ describe('configloader', () => { KEYCLOAK: { ADMIN_SECRET: 'AdminClientSecret', CLIENT_SECRET: 'ClientSecret' }, FRONTEND: { SESSION_SECRET: 'SessionSecret' }, REDIS: { PASSWORD: 'password' }, + ITSLEARNING: { + PASSWORD: 'password', + }, }; beforeEach(() => { @@ -111,6 +119,12 @@ describe('configloader', () => { LOGGING: { DEFAULT_LOG_LEVEL: 'debug', }, + ITSLEARNING: { + ENABLED: true, + ENDPOINT: 'http://itslearning', + USERNAME: 'username', + PASSWORD: 'password', + }, }; it("should not load the secrets file if it can't find it", () => { diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 821650dba..53e59b72a 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -7,3 +7,4 @@ export * from './json.config.js'; export * from './keycloak.config.js'; export * from './redis.config.js'; export * from './server.config.js'; +export * from './itslearning.config.js'; diff --git a/src/shared/config/itslearning.config.ts b/src/shared/config/itslearning.config.ts new file mode 100644 index 000000000..684911739 --- /dev/null +++ b/src/shared/config/itslearning.config.ts @@ -0,0 +1,15 @@ +import { IsBoolean, IsString } from 'class-validator'; + +export class ItsLearningConfig { + @IsBoolean() + public readonly ENABLED!: boolean; + + @IsString() + public readonly ENDPOINT!: string; + + @IsString() + public readonly USERNAME!: string; + + @IsString() + public readonly PASSWORD!: string; +} diff --git a/src/shared/config/json.config.ts b/src/shared/config/json.config.ts index 19cdad70c..12d0054c7 100644 --- a/src/shared/config/json.config.ts +++ b/src/shared/config/json.config.ts @@ -8,6 +8,7 @@ import { KeycloakConfig } from './keycloak.config.js'; import { LoggingConfig } from './logging.config.js'; import { RedisConfig } from './redis.config.js'; import { LdapConfig } from './ldap.config.js'; +import { ItsLearningConfig } from './itslearning.config.js'; export class JsonConfig { @ValidateNested() @@ -41,4 +42,8 @@ export class JsonConfig { @ValidateNested() @Type(() => DataConfig) public readonly DATA!: DataConfig; + + @ValidateNested() + @Type(() => ItsLearningConfig) + public readonly ITSLEARNING!: ItsLearningConfig; } diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts index 137c72367..dcd063149 100644 --- a/src/shared/error/index.ts +++ b/src/shared/error/index.ts @@ -12,4 +12,5 @@ export * from './entity-already-exists.error.js'; export * from './invalid-name.error.js'; export * from './invalid-character-set.error.js'; export * from './invalid-attribute-length.error.js'; +export * from './its-learning.error.js'; export * from './missing-permissions.error.js'; diff --git a/src/shared/error/its-learning.error.ts b/src/shared/error/its-learning.error.ts new file mode 100644 index 000000000..e3297e114 --- /dev/null +++ b/src/shared/error/its-learning.error.ts @@ -0,0 +1,7 @@ +import { DomainError } from './domain.error.js'; + +export class ItsLearningError extends DomainError { + public constructor(message: string, details?: unknown[] | Record) { + super(message, 'ITS_LEARNING_ERROR', details); + } +} diff --git a/test/config.test.json b/test/config.test.json index ba7a5375e..fa3b760e6 100644 --- a/test/config.test.json +++ b/test/config.test.json @@ -42,5 +42,11 @@ }, "LOGGING": { "DEFAULT_LOG_LEVEL": "info" + }, + "ITSLEARNING": { + "ENABLED": false, + "ENDPOINT": "https://itslearning-test.example.com", + "USERNAME": "username", + "PASSWORD": "password" } }