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"
}
}