diff --git a/.github/workflows/reusable_job_nest_test_sonarcloud.yml b/.github/workflows/reusable_job_nest_test_sonarcloud.yml index 20eaffb27..dadceee09 100644 --- a/.github/workflows/reusable_job_nest_test_sonarcloud.yml +++ b/.github/workflows/reusable_job_nest_test_sonarcloud.yml @@ -23,7 +23,7 @@ jobs: fileName: 'secrets.json' fileDir: './config/' # These are placeholder secrets without any significance - encodedString: ewogICAgIkRCIjogewogICAgICAgICJTRUNSRVQiOiAiVmVyeSBoaWRkZW4gc2VjcmV0IgogICAgfSwKICAgICJLRVlDTE9BSyI6IHsKICAgICAgICAiU0VDUkVUIjogIkNsaWVudCBTZWNyZXQiCiAgICB9Cn0= + encodedString: ewogICAgIkRCIjogewogICAgICAgICJTRUNSRVQiOiAiVmVyeSBoaWRkZW4gc2VjcmV0IgogICAgfSwKICAgICJLRVlDTE9BSyI6IHsKICAgICAgICAiQURNSU5fU0VDUkVUIjogIkNsaWVudCBTZWNyZXQiCiAgICB9Cn0= - name: Setup node uses: actions/setup-node@v3 with: diff --git a/config/config.dev.json b/config/config.dev.json index d60c9c6f7..6cd518fa6 100644 --- a/config/config.dev.json +++ b/config/config.dev.json @@ -12,8 +12,9 @@ }, "KEYCLOAK": { "BASE_URL": "http://127.0.0.1:8080", - "REALM_NAME": "master", - "CLIENT_ID": "admin-cli", - "SECRET": "topsecret" + "ADMIN_REALM_NAME": "master", + "ADMIN_CLIENT_ID": "admin-cli", + "REALM_NAME": "schulportal", + "CLIENT_ID": "schulportal" } } diff --git a/config/config.test.json b/config/config.test.json index 98666166a..3d76a02c5 100644 --- a/config/config.test.json +++ b/config/config.test.json @@ -12,7 +12,9 @@ }, "KEYCLOAK": { "BASE_URL": "http://127.0.0.1:8080", - "REALM_NAME": "master", - "CLIENT_ID": "admin-cli" + "ADMIN_REALM_NAME": "master", + "ADMIN_CLIENT_ID": "admin-cli", + "REALM_NAME": "schulportal", + "CLIENT_ID": "schulportal" } } diff --git a/package-lock.json b/package-lock.json index 218b99fb6..86b2efd6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lodash-es": "^4.17.21", "nest-commander": "^3.11.0", "nest-keycloak-connect": "^1.9.2", + "openid-client": "^5.6.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" }, @@ -7428,6 +7429,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.2.tgz", + "integrity": "sha512-IY73F228OXRl9ar3jJagh7Vnuhj/GzBunPiZP13K0lOl7Am9SoWW3kEzq3MCllJMTtZqHTiDXQvoRd4U95aU6A==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8172,6 +8181,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -8270,6 +8287,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8304,6 +8329,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.0.tgz", + "integrity": "sha512-uFTkN/iqgKvSnmpVAS/T6SNThukRMBcmymTQ71Ngus1F60tdtKVap7zCrleocY+fogPtpmoxi5Q1YdrgYuTlkA==", + "dependencies": { + "jose": "^4.15.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", diff --git a/package.json b/package.json index 2d72ab486..e9af271e2 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lodash-es": "^4.17.21", "nest-commander": "^3.11.0", "nest-keycloak-connect": "^1.9.2", + "openid-client": "^5.6.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0" }, diff --git a/src/modules/health/health.controller.spec.ts b/src/modules/health/health.controller.spec.ts index 8a1daf2dd..659c9e46c 100644 --- a/src/modules/health/health.controller.spec.ts +++ b/src/modules/health/health.controller.spec.ts @@ -20,10 +20,12 @@ describe('HealthController', () => { let entityManager: SqlEntityManager; let httpHealthIndicator: DeepMocked; const keycloakConfig: KeycloakConfig = { - CLIENT_ID: '', - SECRET: '', - REALM_NAME: '', + ADMIN_CLIENT_ID: '', + ADMIN_SECRET: '', + ADMIN_REALM_NAME: '', BASE_URL: 'http://keycloak.test', + REALM_NAME: '', + CLIENT_ID: '', }; let configService: DeepMocked; diff --git a/src/modules/keycloak-administration/domain/keycloak-admin-client.service.ts b/src/modules/keycloak-administration/domain/keycloak-admin-client.service.ts index 8a4a6b217..17311ede4 100644 --- a/src/modules/keycloak-administration/domain/keycloak-admin-client.service.ts +++ b/src/modules/keycloak-administration/domain/keycloak-admin-client.service.ts @@ -18,7 +18,7 @@ export class KeycloakAdministrationService { this.kcAdminClient.setConfig({ baseUrl: this.kcConfig.BASE_URL, - realmName: this.kcConfig.REALM_NAME, + realmName: this.kcConfig.ADMIN_REALM_NAME, }); } @@ -45,8 +45,8 @@ export class KeycloakAdministrationService { try { const credentials: Credentials = { grantType: 'client_credentials', - clientId: this.kcConfig.CLIENT_ID, - clientSecret: this.kcConfig.SECRET, + clientId: this.kcConfig.ADMIN_CLIENT_ID, + clientSecret: this.kcConfig.ADMIN_SECRET, }; await this.kcAdminClient.auth(credentials); diff --git a/src/modules/person/api/finde-persondatensatz-dto.ts b/src/modules/person/api/find-personendatensatz.dto.ts similarity index 57% rename from src/modules/person/api/finde-persondatensatz-dto.ts rename to src/modules/person/api/find-personendatensatz.dto.ts index baea04607..d37e61d37 100644 --- a/src/modules/person/api/finde-persondatensatz-dto.ts +++ b/src/modules/person/api/find-personendatensatz.dto.ts @@ -1,7 +1,8 @@ import { AutoMap } from '@automapper/classes'; import { PagedDto } from '../../../shared/paging/index.js'; +import { SichtfreigabeType } from './personen-query.param.js'; -export class FindPersonDatensatzDTO extends PagedDto { +export class FindPersonendatensatzDto extends PagedDto { @AutoMap() public referrer?: string; @@ -10,4 +11,7 @@ export class FindPersonDatensatzDTO extends PagedDto { @AutoMap() public vorname?: string; + + @AutoMap() + public sichtfreigabe!: SichtfreigabeType; } diff --git a/src/modules/person/api/find-personenkontext.dto.ts b/src/modules/person/api/find-personenkontext.dto.ts new file mode 100644 index 000000000..7590e647e --- /dev/null +++ b/src/modules/person/api/find-personenkontext.dto.ts @@ -0,0 +1,20 @@ +import { AutoMap } from '@automapper/classes'; +import { Rolle, Personenstatus } from '../domain/personenkontext.enums.js'; +import { SichtfreigabeType } from './personen-query.param.js'; + +export class FindPersonenkontextDto { + @AutoMap() + public personId!: string; + + @AutoMap() + public readonly referrer?: string; + + @AutoMap() + public readonly rolle?: Rolle; + + @AutoMap() + public readonly personenstatus?: Personenstatus; + + @AutoMap() + public readonly sichtfreigabe: SichtfreigabeType = SichtfreigabeType.NEIN; +} diff --git a/src/modules/person/api/person-api.mapper.profile.spec.ts b/src/modules/person/api/person-api.mapper.profile.spec.ts index eb598005c..bb1e25cee 100644 --- a/src/modules/person/api/person-api.mapper.profile.spec.ts +++ b/src/modules/person/api/person-api.mapper.profile.spec.ts @@ -22,6 +22,8 @@ import { Jahrgangsstufe, Personenstatus, Rolle } from '../domain/personenkontext import { PersonenkontextDo } from '../domain/personenkontext.do.js'; import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; import { PersonenkontextResponse } from './personenkontext.response.js'; +import { PersonenkontextQueryParams } from './personenkontext-query.params.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; describe('PersonApiMapperProfile', () => { let module: TestingModule; @@ -166,8 +168,8 @@ describe('PersonApiMapperProfile', () => { }); it('should map PersonenkontextDo to CreatedPersonenkontextDto', () => { - const personDo: PersonenkontextDo = DoFactory.createPersonenkontext(true); - expect(() => sut.map(personDo, PersonenkontextDo, CreatedPersonenkontextDto)).not.toThrowError( + const personenkontextDo: PersonenkontextDo = DoFactory.createPersonenkontext(true); + expect(() => sut.map(personenkontextDo, PersonenkontextDo, CreatedPersonenkontextDto)).not.toThrowError( MappingError, ); }); @@ -189,5 +191,35 @@ describe('PersonApiMapperProfile', () => { MappingError, ); }); + + it('should map PersonenkontextQueryParams to FindePersonenkontextDto', () => { + const params: PersonenkontextQueryParams = { + sichtfreigabe: SichtfreigabeType.JA, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + rolle: Rolle.LERNENDER, + }; + expect(() => sut.map(params, PersonenkontextQueryParams, FindPersonenkontextDto)).not.toThrowError( + MappingError, + ); + }); + + it('should map FindePersonenkontextDto to PersonenkontextDo', () => { + const dto: FindPersonenkontextDto = { + personId: faker.string.uuid(), + sichtfreigabe: SichtfreigabeType.JA, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + rolle: Rolle.LERNENDER, + }; + expect(() => sut.map(dto, FindPersonenkontextDto, PersonenkontextDo)).not.toThrowError(MappingError); + }); + + it('should map PersonenkontextDo to PersonenkontextResponse', () => { + const personenkontextDo: PersonenkontextDo = DoFactory.createPersonenkontext(true); + expect(() => sut.map(personenkontextDo, PersonenkontextDo, PersonenkontextResponse)).not.toThrowError( + MappingError, + ); + }); }); }); diff --git a/src/modules/person/api/person-api.mapper.profile.ts b/src/modules/person/api/person-api.mapper.profile.ts index 592e3f6b6..8bd270618 100644 --- a/src/modules/person/api/person-api.mapper.profile.ts +++ b/src/modules/person/api/person-api.mapper.profile.ts @@ -15,10 +15,10 @@ import { CreatePersonDto } from '../domain/create-person.dto.js'; import { PersonDo } from '../domain/person.do.js'; import { Gender, TrustLevel } from '../domain/person.enums.js'; import { CreatePersonBodyParams } from './create-person.body.params.js'; -import { FindPersonDatensatzDTO } from './finde-persondatensatz-dto.js'; +import { FindPersonendatensatzDto } from './find-personendatensatz.dto.js'; import { PersonGender, PersonTrustLevel } from './person.enums.js'; import { PersonenQueryParams, SichtfreigabeType } from './personen-query.param.js'; -import { PersonenDatensatz } from './personendatensatz.js'; +import { PersonendatensatzResponse } from './personendatensatz.response.js'; import { CreatePersonenkontextBodyParams } from './create-personenkontext.body.params.js'; import { CreatePersonenkontextDto } from './create-personenkontext.dto.js'; import { PersonenkontextDo } from '../domain/personenkontext.do.js'; @@ -26,6 +26,8 @@ import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; import { PersonenkontextResponse } from './personenkontext.response.js'; import { OrganisationDo } from '../../organisation/domain/organisation.do.js'; import { CreatedPersonenkontextOrganisationDto } from './created-personenkontext-organisation.dto.js'; +import { PersonenkontextQueryParams } from './personenkontext-query.params.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; export const personGenderToGenderConverter: Converter = { convert(source: PersonGender): Gender { @@ -146,112 +148,112 @@ export class PersonApiMapperProfile extends AutomapperProfile { createMap( mapper, PersonDo, - PersonenDatensatz, + PersonendatensatzResponse, forMember( - (dest: PersonenDatensatz) => dest.person.id, + (dest: PersonendatensatzResponse) => dest.person.id, mapFrom((src: PersonDo) => src.id), ), forMember( - (dest: PersonenDatensatz) => dest.person.mandant, + (dest: PersonendatensatzResponse) => dest.person.mandant, mapFrom((src: PersonDo) => src.client), ), forMember( - (dest: PersonenDatensatz) => dest.person.referrer, + (dest: PersonendatensatzResponse) => dest.person.referrer, mapFrom((src: PersonDo) => src.referrer), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.vorname, + (dest: PersonendatensatzResponse) => dest.person.name.vorname, mapFrom((src: PersonDo) => src.firstName), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.rufname, + (dest: PersonendatensatzResponse) => dest.person.name.rufname, mapFrom((src: PersonDo) => src.nickName), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.familienname, + (dest: PersonendatensatzResponse) => dest.person.name.familienname, mapFrom((src: PersonDo) => src.lastName), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.initialenvorname, + (dest: PersonendatensatzResponse) => dest.person.name.initialenvorname, mapFrom((src: PersonDo) => src.initialsFirstName), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.initialenfamilienname, + (dest: PersonendatensatzResponse) => dest.person.name.initialenfamilienname, mapFrom((src: PersonDo) => src.initialsLastName), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.sortierindex, + (dest: PersonendatensatzResponse) => dest.person.name.sortierindex, mapFrom((src: PersonDo) => src.nameSortIndex), ), forMember( - (dest: PersonenDatensatz) => dest.person.geburt.datum, + (dest: PersonendatensatzResponse) => dest.person.geburt.datum, mapFrom((src: PersonDo) => src.birthDate), ), forMember( - (dest: PersonenDatensatz) => dest.person.geburt.geburtsort, + (dest: PersonendatensatzResponse) => dest.person.geburt.geburtsort, mapFrom((src: PersonDo) => src.birthPlace), ), forMember( - (dest: PersonenDatensatz) => dest.person.vertrauensstufe, + (dest: PersonendatensatzResponse) => dest.person.vertrauensstufe, mapFrom((src: PersonDo) => src.trustLevel), ), forMember( - (dest: PersonenDatensatz) => dest.person.geschlecht, + (dest: PersonendatensatzResponse) => dest.person.geschlecht, mapFrom((src: PersonDo) => src.gender), ), forMember( - (dest: PersonenDatensatz) => dest.person.lokalisierung, + (dest: PersonendatensatzResponse) => dest.person.lokalisierung, mapFrom((src: PersonDo) => src.localization), ), forMember( - (dest: PersonenDatensatz) => dest.person.stammorganisation, + (dest: PersonendatensatzResponse) => dest.person.stammorganisation, mapFrom((src: PersonDo) => src.mainOrganization), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.anrede, + (dest: PersonendatensatzResponse) => dest.person.name.anrede, mapFrom((src: PersonDo) => src.nameSalutation), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.namenssuffix, + (dest: PersonendatensatzResponse) => dest.person.name.namenssuffix, mapFrom((src: PersonDo) => src.nameSuffix), ), forMember( - (dest: PersonenDatensatz) => dest.person.name.namenspraefix, + (dest: PersonendatensatzResponse) => dest.person.name.namenspraefix, mapFrom((src: PersonDo) => src.namePrefix), ), ); createMap( mapper, PersonenQueryParams, - FindPersonDatensatzDTO, + FindPersonendatensatzDto, forMember( - (dest: FindPersonDatensatzDTO) => dest.vorname, + (dest: FindPersonendatensatzDto) => dest.vorname, mapFrom((src: PersonenQueryParams) => src.vorname), ), forMember( - (dest: FindPersonDatensatzDTO) => dest.familienname, + (dest: FindPersonendatensatzDto) => dest.familienname, mapFrom((src: PersonenQueryParams) => src.familienname), ), forMember( - (dest: FindPersonDatensatzDTO) => dest.referrer, + (dest: FindPersonendatensatzDto) => dest.referrer, mapFrom((src: PersonenQueryParams) => src.referrer), ), ); createMap( mapper, - FindPersonDatensatzDTO, + FindPersonendatensatzDto, PersonDo, forMember( (dest: PersonDo) => dest.lastName, - mapFrom((src: FindPersonDatensatzDTO) => src.familienname), + mapFrom((src: FindPersonendatensatzDto) => src.familienname), ), forMember( (dest: PersonDo) => dest.firstName, - mapFrom((src: FindPersonDatensatzDTO) => src.vorname), + mapFrom((src: FindPersonendatensatzDto) => src.vorname), ), forMember( (dest: PersonDo) => dest.referrer, - mapFrom((src: FindPersonDatensatzDTO) => src.referrer), + mapFrom((src: FindPersonendatensatzDto) => src.referrer), ), forMember((dest: PersonDo) => dest.id, ignore()), forMember((dest: PersonDo) => dest.createdAt, ignore()), @@ -303,6 +305,39 @@ export class PersonApiMapperProfile extends AutomapperProfile { ); createMap(mapper, OrganisationDo, CreatedPersonenkontextOrganisationDto); createMap(mapper, CreatedPersonenkontextDto, PersonenkontextResponse); + + createMap( + mapper, + PersonenkontextQueryParams, + FindPersonenkontextDto, + forMember((dest: FindPersonenkontextDto) => dest.personId, ignore()), + ); + createMap( + mapper, + FindPersonenkontextDto, + PersonenkontextDo, + forMember((dest: PersonenkontextDo) => dest.mandant, ignore()), + forMember((dest: PersonenkontextDo) => dest.organisation, ignore()), + forMember((dest: PersonenkontextDo) => dest.jahrgangsstufe, ignore()), + forMember((dest: PersonenkontextDo) => dest.loeschungZeitpunkt, ignore()), + forMember((dest: PersonenkontextDo) => dest.revision, ignore()), + forMember( + (dest: PersonenkontextDo) => dest.sichtfreigabe, + convertUsing( + personVisibilityToBooleanConverter, + (src: FindPersonenkontextDto) => src.sichtfreigabe, + ), + ), + ); + createMap( + mapper, + PersonenkontextDo, + PersonenkontextResponse, + forMember( + (dest: PersonenkontextResponse) => dest.id, + mapFrom((src: PersonenkontextDo) => src.id), + ), + ); }; } } diff --git a/src/modules/person/api/person.controller.spec.ts b/src/modules/person/api/person.controller.spec.ts index 41b90b8a2..c841e63fc 100644 --- a/src/modules/person/api/person.controller.spec.ts +++ b/src/modules/person/api/person.controller.spec.ts @@ -9,15 +9,17 @@ import { PersonUc } from './person.uc.js'; import { PersonByIdParams } from './person-by-id.param.js'; import { PersonResponse } from './person.response.js'; import { HttpException } from '@nestjs/common'; -import { PersonenQueryParams } from './personen-query.param.js'; +import { PersonenQueryParams, SichtfreigabeType } from './personen-query.param.js'; import { PersonBirthParams } from './person-birth.params.js'; import { TrustLevel } from '../domain/person.enums.js'; -import { PersonenDatensatz } from './personendatensatz.js'; +import { PersonendatensatzResponse } from './personendatensatz.response.js'; import { PersonenkontextUc } from './personenkontext.uc.js'; import { CreatePersonenkontextBodyParams } from './create-personenkontext.body.params.js'; import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; import { Jahrgangsstufe, Personenstatus, Rolle } from '../domain/personenkontext.enums.js'; import { PagedResponse } from '../../../shared/paging/index.js'; +import { PersonenkontextResponse } from './personenkontext.response.js'; +import { PersonenkontextQueryParams } from './personenkontext-query.params.js'; describe('PersonController', () => { let module: TestingModule; @@ -108,8 +110,9 @@ describe('PersonController', () => { lokalisierung: faker.location.country(), vertrauensstufe: TrustLevel.TRUSTED, }; - const persondatensatz: PersonenDatensatz = { + const persondatensatz: PersonendatensatzResponse = { person: personResponse, + personenkontexte: [], }; personUcMock.findPersonById.mockResolvedValue(persondatensatz); await expect(personController.findPersonById(params)).resolves.not.toThrow(); @@ -138,6 +141,7 @@ describe('PersonController', () => { referrer: options.referrer, familienname: options.lastName, vorname: options.firstName, + sichtfreigabe: SichtfreigabeType.NEIN, }; it('should get all persons', async () => { @@ -167,13 +171,16 @@ describe('PersonController', () => { lokalisierung: '', vertrauensstufe: TrustLevel.TRUSTED, }; - const mockPersondatensatz1: PersonenDatensatz = { + + const mockPersondatensatz1: PersonendatensatzResponse = { person: person1, + personenkontexte: [], }; - const mockPersondatensatz2: PersonenDatensatz = { + const mockPersondatensatz2: PersonendatensatzResponse = { person: person2, + personenkontexte: [], }; - const mockPersondatensatz: PagedResponse = new PagedResponse({ + const mockPersondatensatz: PagedResponse = new PagedResponse({ offset: 0, limit: 10, total: 2, @@ -182,7 +189,7 @@ describe('PersonController', () => { personUcMock.findAll.mockResolvedValue(mockPersondatensatz); - const result: PagedResponse = await personController.findPersons(queryParams); + const result: PagedResponse = await personController.findPersons(queryParams); expect(personUcMock.findAll).toHaveBeenCalledTimes(1); expect(result.items.at(0)?.person.referrer).toEqual(queryParams.referrer); @@ -192,33 +199,75 @@ describe('PersonController', () => { }); }); - describe('when creating a personenkontext', () => { - it('should not throw', async () => { - const pathParams: PersonByIdParams = { - personId: faker.string.uuid(), - }; - const body: CreatePersonenkontextBodyParams = { - rolle: Rolle.LEHRENDER, - jahrgangsstufe: Jahrgangsstufe.JAHRGANGSSTUFE_1, - personenstatus: Personenstatus.AKTIV, - referrer: 'referrer', - }; - const ucResult: CreatedPersonenkontextDto = { - id: faker.string.uuid(), - mandant: faker.string.uuid(), - organisation: { + describe('createPersonenkontext', () => { + describe('when creating a personenkontext', () => { + it('should not throw', async () => { + const pathParams: PersonByIdParams = { + personId: faker.string.uuid(), + }; + const body: CreatePersonenkontextBodyParams = { + rolle: Rolle.LEHRENDER, + jahrgangsstufe: Jahrgangsstufe.JAHRGANGSSTUFE_1, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + }; + const ucResult: CreatedPersonenkontextDto = { id: faker.string.uuid(), - }, - revision: '1', - rolle: Rolle.LEHRENDER, - jahrgangsstufe: Jahrgangsstufe.JAHRGANGSSTUFE_1, - personenstatus: Personenstatus.AKTIV, - referrer: 'referrer', - }; - personenkontextUcMock.createPersonenkontext.mockResolvedValue(ucResult); + mandant: faker.string.uuid(), + organisation: { + id: faker.string.uuid(), + }, + revision: '1', + rolle: Rolle.LEHRENDER, + jahrgangsstufe: Jahrgangsstufe.JAHRGANGSSTUFE_1, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + }; + personenkontextUcMock.createPersonenkontext.mockResolvedValue(ucResult); - await expect(personController.createPersonenkontext(pathParams, body)).resolves.not.toThrow(); - expect(personenkontextUcMock.createPersonenkontext).toHaveBeenCalledTimes(1); + await expect(personController.createPersonenkontext(pathParams, body)).resolves.not.toThrow(); + expect(personenkontextUcMock.createPersonenkontext).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('findPersonenkontexte', () => { + describe('When fetching personenkontexte is successful', () => { + it('should get all personenkontexte', async () => { + const pathParams: PersonByIdParams = { + personId: faker.string.uuid(), + }; + const queryParams: PersonenkontextQueryParams = { + referrer: 'referrer', + sichtfreigabe: SichtfreigabeType.NEIN, + personenstatus: Personenstatus.AKTIV, + rolle: Rolle.LERNENDER, + }; + const personenkontextResponse: PersonenkontextResponse = { + id: faker.string.uuid(), + organisation: { + id: faker.string.uuid(), + }, + revision: '1', + mandant: faker.string.uuid(), + rolle: Rolle.LERNENDER, + referrer: 'referrer', + jahrgangsstufe: Jahrgangsstufe.JAHRGANGSSTUFE_1, + personenstatus: Personenstatus.AKTIV, + }; + const personenkontextResponseArray: PersonenkontextResponse[] = [personenkontextResponse]; + + personenkontextUcMock.findAll.mockResolvedValue(personenkontextResponseArray); + + const result: PersonenkontextResponse[] = await personController.findPersonenkontexte( + pathParams, + queryParams, + ); + + expect(personenkontextUcMock.findAll).toHaveBeenCalledTimes(1); + expect(result.length).toBe(1); + expect(result[0]?.id).toBe(personenkontextResponseArray[0]?.id); + }); }); }); }); diff --git a/src/modules/person/api/person.controller.ts b/src/modules/person/api/person.controller.ts index 45952a373..dc5e2b389 100644 --- a/src/modules/person/api/person.controller.ts +++ b/src/modules/person/api/person.controller.ts @@ -15,15 +15,17 @@ import { PersonUc } from '../api/person.uc.js'; import { CreatePersonBodyParams } from './create-person.body.params.js'; import { CreatePersonDto } from '../domain/create-person.dto.js'; import { PersonByIdParams } from './person-by-id.param.js'; -import { PersonenQueryParams } from './personen-query.param.js'; -import { FindPersonDatensatzDTO } from './finde-persondatensatz-dto.js'; -import { PersonenDatensatz } from './personendatensatz.js'; import { Paged, PagedResponse, PagingHeadersObject } from '../../../shared/paging/index.js'; +import { PersonenQueryParams } from './personen-query.param.js'; +import { FindPersonendatensatzDto } from './find-personendatensatz.dto.js'; +import { PersonendatensatzResponse } from './personendatensatz.response.js'; import { CreatePersonenkontextBodyParams } from './create-personenkontext.body.params.js'; import { CreatePersonenkontextDto } from './create-personenkontext.dto.js'; import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; import { PersonenkontextResponse } from './personenkontext.response.js'; import { PersonenkontextUc } from './personenkontext.uc.js'; +import { PersonenkontextQueryParams } from './personenkontext-query.params.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; @ApiTags('person') @Controller({ path: 'person' }) @@ -52,9 +54,9 @@ export class PersonController { @ApiNotFoundResponse({ description: 'The person does not exist.' }) @ApiForbiddenResponse({ description: 'Insufficient permissions to get the person.' }) @ApiInternalServerErrorResponse({ description: 'Internal server error while getting the person.' }) - public async findPersonById(@Param() params: PersonByIdParams): Promise { + public async findPersonById(@Param() params: PersonByIdParams): Promise { try { - const person: PersonenDatensatz = await this.personUc.findPersonById(params.personId); + const person: PersonendatensatzResponse = await this.personUc.findPersonById(params.personId); return person; } catch (error) { throw new HttpException('Requested entity does not exist', HttpStatus.NOT_FOUND); @@ -85,23 +87,50 @@ export class PersonController { return this.mapper.map(createdPersonenkontext, CreatedPersonenkontextDto, PersonenkontextResponse); } + @Get(':personId/personenkontexte') + @ApiOkResponse({ description: 'The personenkontexte were successfully pulled.' }) + @ApiUnauthorizedResponse({ description: 'Not authorized to get personenkontexte.' }) + @ApiForbiddenResponse({ description: 'Insufficient permissions to get personenkontexte.' }) + @ApiNotFoundResponse({ description: 'No personenkontexte were found.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error while getting all personenkontexte.' }) + public async findPersonenkontexte( + @Param() pathParams: PersonByIdParams, + @Query() queryParams: PersonenkontextQueryParams, + ): Promise { + const findPersonenkontextDto: FindPersonenkontextDto = this.mapper.map( + queryParams, + PersonenkontextQueryParams, + FindPersonenkontextDto, + ); + + findPersonenkontextDto.personId = pathParams.personId; + + const personenkontexte: PersonenkontextResponse[] = await this.personenkontextUc.findAll( + findPersonenkontextDto, + ); + + return personenkontexte; + } + @Get() @ApiOkResponse({ description: 'The persons were successfully returned.', - type: [PersonenDatensatz], + type: [PersonendatensatzResponse], headers: PagingHeadersObject, }) @ApiUnauthorizedResponse({ description: 'Not authorized to get persons.' }) @ApiForbiddenResponse({ description: 'Insufficient permissions to get persons.' }) @ApiInternalServerErrorResponse({ description: 'Internal server error while getting all persons.' }) - public async findPersons(@Query() queryParams: PersonenQueryParams): Promise> { - const personDatensatzDTO: FindPersonDatensatzDTO = this.mapper.map( + public async findPersons( + @Query() queryParams: PersonenQueryParams, + ): Promise> { + const personDatensatzDTO: FindPersonendatensatzDto = this.mapper.map( queryParams, PersonenQueryParams, - FindPersonDatensatzDTO, + FindPersonendatensatzDto, ); - const persons: Paged = await this.personUc.findAll(personDatensatzDTO); - const response: PagedResponse = new PagedResponse(persons); + const persons: Paged = await this.personUc.findAll(personDatensatzDTO); + const response: PagedResponse = new PagedResponse(persons); return response; } diff --git a/src/modules/person/api/person.uc.spec.ts b/src/modules/person/api/person.uc.spec.ts index 6eb332b32..4be14b706 100644 --- a/src/modules/person/api/person.uc.spec.ts +++ b/src/modules/person/api/person.uc.spec.ts @@ -6,17 +6,20 @@ import { CreatePersonDto } from '../domain/create-person.dto.js'; import { PersonService } from '../domain/person.service.js'; import { PersonApiMapperProfile } from './person-api.mapper.profile.js'; import { PersonUc } from './person.uc.js'; -import { FindPersonDatensatzDTO } from './finde-persondatensatz-dto.js'; +import { FindPersonendatensatzDto } from './find-personendatensatz.dto.js'; import { faker } from '@faker-js/faker'; import { PersonDo } from '../domain/person.do.js'; -import { PersonenDatensatz } from './personendatensatz.js'; +import { PersonendatensatzResponse } from './personendatensatz.response.js'; import { KeycloakUserService } from '../../keycloak-administration/index.js'; import { Paged } from '../../../shared/paging/index.js'; +import { SichtfreigabeType } from './personen-query.param.js'; +import { PersonenkontextService } from '../domain/personenkontext.service.js'; describe('PersonUc', () => { let module: TestingModule; let personUc: PersonUc; let personServiceMock: DeepMocked; + let personenkontextServiceMock: DeepMocked; let userServiceMock: DeepMocked; beforeAll(async () => { @@ -29,6 +32,10 @@ describe('PersonUc', () => { provide: PersonService, useValue: createMock(), }, + { + provide: PersonenkontextService, + useValue: createMock(), + }, { provide: KeycloakUserService, useValue: createMock(), @@ -37,6 +44,7 @@ describe('PersonUc', () => { }).compile(); personUc = module.get(PersonUc); personServiceMock = module.get(PersonService); + personenkontextServiceMock = module.get(PersonenkontextService); userServiceMock = module.get(KeycloakUserService); }); @@ -121,7 +129,13 @@ describe('PersonUc', () => { ok: true, value: DoFactory.createPerson(true), }); + + personenkontextServiceMock.findAllPersonenkontexte.mockResolvedValue({ + ok: true, + value: [DoFactory.createPersonenkontext(true)], + }); await expect(personUc.findPersonById(id)).resolves.not.toThrow(); + expect(personenkontextServiceMock.findAllPersonenkontexte).toHaveBeenCalledTimes(1); }); }); @@ -134,13 +148,30 @@ describe('PersonUc', () => { await expect(personUc.findPersonById(id)).rejects.toThrowError(EntityNotFoundError); }); }); + + describe('When no personenkontexte are found', () => { + it('should not throw', async () => { + personServiceMock.findPersonById.mockResolvedValue({ + ok: true, + value: DoFactory.createPerson(true), + }); + + personenkontextServiceMock.findAllPersonenkontexte.mockResolvedValue({ + ok: false, + error: new EntityNotFoundError('Personenkontext'), + }); + await expect(personUc.findPersonById(id)).resolves.not.toThrow(); + expect(personenkontextServiceMock.findAllPersonenkontexte).toHaveBeenCalledTimes(1); + }); + }); }); describe('findAll', () => { - const personDTO: FindPersonDatensatzDTO = { + const personDTO: FindPersonendatensatzDto = { referrer: '', familienname: '', vorname: '', + sichtfreigabe: SichtfreigabeType.NEIN, }; it('should find all persons that match with query param', async () => { @@ -154,10 +185,15 @@ describe('PersonUc', () => { }; personServiceMock.findAllPersons.mockResolvedValue(persons); + personenkontextServiceMock.findAllPersonenkontexte.mockResolvedValue({ + ok: true, + value: [DoFactory.createPersonenkontext(true)], + }); - const result: Paged = await personUc.findAll(personDTO); + const result: Paged = await personUc.findAll(personDTO); - expect(result.items).toHaveLength(2); + expect(personenkontextServiceMock.findAllPersonenkontexte).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); expect(result.items.at(0)?.person.name.vorname).toEqual(firstPerson.firstName); expect(result.items.at(0)?.person.name.familienname).toEqual(firstPerson.lastName); expect(result.items.at(1)?.person.name.vorname).toEqual(secondPerson.firstName); @@ -166,8 +202,11 @@ describe('PersonUc', () => { it('should return an empty array when no matching persons are found', async () => { const emptyResult: Paged> = { offset: 0, limit: 0, total: 0, items: [] }; + personServiceMock.findAllPersons.mockResolvedValue(emptyResult); - const result: Paged = await personUc.findAll(personDTO); + + const result: Paged = await personUc.findAll(personDTO); + expect(result.items).toEqual([]); }); }); diff --git a/src/modules/person/api/person.uc.ts b/src/modules/person/api/person.uc.ts index 2640e8d08..332bbf9c1 100644 --- a/src/modules/person/api/person.uc.ts +++ b/src/modules/person/api/person.uc.ts @@ -5,14 +5,21 @@ import { KeycloakUserService, UserDo } from '../../keycloak-administration/index import { CreatePersonDto } from '../domain/create-person.dto.js'; import { PersonService } from '../domain/person.service.js'; import { PersonDo } from '../domain/person.do.js'; -import { FindPersonDatensatzDTO as FindPersonDatensatzDto } from './finde-persondatensatz-dto.js'; -import { PersonenDatensatz } from './personendatensatz.js'; import { Paged } from '../../../shared/paging/index.js'; +import { FindPersonendatensatzDto } from './find-personendatensatz.dto.js'; +import { PersonendatensatzResponse } from './personendatensatz.response.js'; +import { PersonenkontextService } from '../domain/personenkontext.service.js'; +import { SichtfreigabeType } from './personen-query.param.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; +import { PersonenkontextDo } from '../domain/personenkontext.do.js'; +import { PersonenkontextResponse } from './personenkontext.response.js'; +import { DomainError } from '../../../shared/error/domain.error.js'; @Injectable() export class PersonUc { public constructor( private readonly personService: PersonService, + private readonly personenkontextService: PersonenkontextService, private readonly userService: KeycloakUserService, @Inject(getMapperToken()) private readonly mapper: Mapper, ) {} @@ -43,17 +50,23 @@ export class PersonUc { } } - public async findPersonById(id: string): Promise { + public async findPersonById(id: string): Promise { const result: Result> = await this.personService.findPersonById(id); if (result.ok) { - const person: PersonenDatensatz = this.mapper.map(result.value, PersonDo, PersonenDatensatz); + const person: PersonendatensatzResponse = this.mapper.map( + result.value, + PersonDo, + PersonendatensatzResponse, + ); + person.personenkontexte = await this.findPersonenkontexteForPerson(id, SichtfreigabeType.NEIN); + return person; } throw result.error; } - public async findAll(personDto: FindPersonDatensatzDto): Promise> { - const personDo: PersonDo = this.mapper.map(personDto, FindPersonDatensatzDto, PersonDo); + public async findAll(personDto: FindPersonendatensatzDto): Promise> { + const personDo: PersonDo = this.mapper.map(personDto, FindPersonendatensatzDto, PersonDo); const result: Paged> = await this.personService.findAllPersons( personDo, personDto.offset, @@ -69,10 +82,17 @@ export class PersonUc { }; } - const persons: PersonenDatensatz[] = result.items.map((person: PersonDo) => - this.mapper.map(person, PersonDo, PersonenDatensatz), + const persons: PersonendatensatzResponse[] = result.items.map((person: PersonDo) => + this.mapper.map(person, PersonDo, PersonendatensatzResponse), ); + for (const person of persons) { + person.personenkontexte = await this.findPersonenkontexteForPerson( + person.person.id, + personDto.sichtfreigabe, + ); + } + return { total: result.total, offset: result.offset, @@ -80,4 +100,29 @@ export class PersonUc { items: persons, }; } + + private async findPersonenkontexteForPerson( + personId: string, + sichtfreigabe: SichtfreigabeType, + ): Promise { + const personenkontextFilter: FindPersonenkontextDto = { + personId: personId, + sichtfreigabe: sichtfreigabe, + }; + const result: Result[], DomainError> = + await this.personenkontextService.findAllPersonenkontexte( + this.mapper.map(personenkontextFilter, FindPersonenkontextDto, PersonenkontextDo), + ); + + if (!result.ok) { + return []; + } + + const personenkontextResponses: PersonenkontextResponse[] = result.value.map( + (personenkontext: PersonenkontextDo) => + this.mapper.map(personenkontext, PersonenkontextDo, PersonenkontextResponse), + ); + + return personenkontextResponses; + } } diff --git a/src/modules/person/api/personen-query.param.spec.ts b/src/modules/person/api/personen-query.param.spec.ts new file mode 100644 index 000000000..65659e4d5 --- /dev/null +++ b/src/modules/person/api/personen-query.param.spec.ts @@ -0,0 +1,25 @@ +import { plainToInstance } from 'class-transformer'; +import 'reflect-metadata'; +import { PersonenQueryParam, SichtfreigabeType } from './personen-query.param.js'; +import { faker } from '@faker-js/faker'; + +describe('PersonenQueryParam', () => { + const referenceParams: PersonenQueryParam = { + sichtfreigabe: SichtfreigabeType.JA, + familienname: faker.person.lastName(), + referrer: 'referrer', + vorname: faker.person.firstName(), + }; + + it('should convert a plain object to a class of PersonenQueryParam', () => { + const incomingParams: object = { + sichtfreigabe: referenceParams.sichtfreigabe, + familienname: referenceParams.familienname, + referrer: referenceParams.referrer, + vorname: referenceParams.vorname, + }; + const mappedParams: PersonenQueryParam = plainToInstance(PersonenQueryParam, incomingParams, {}); + expect(mappedParams).toBeInstanceOf(PersonenQueryParam); + expect(mappedParams).toEqual(referenceParams); + }); +}); diff --git a/src/modules/person/api/personen-query.param.ts b/src/modules/person/api/personen-query.param.ts index 387680ff2..b82fe59a4 100644 --- a/src/modules/person/api/personen-query.param.ts +++ b/src/modules/person/api/personen-query.param.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; import { AutoMap } from '@automapper/classes'; import { PagedQueryParams } from '../../../shared/paging/index.js'; @@ -38,8 +38,7 @@ export class PersonenQueryParams extends PagedQueryParams { }) public readonly vorname?: string; - // this property would be needed for person context. - /* @AutoMap() + @AutoMap() @IsOptional() @IsEnum(SichtfreigabeType) @ApiProperty({ @@ -49,5 +48,5 @@ export class PersonenQueryParams extends PagedQueryParams { required: false, nullable: true, }) - public readonly sichtfreigabe: SichtfreigabeType = SichtfreigabeType.NEIN;*/ + public readonly sichtfreigabe: SichtfreigabeType = SichtfreigabeType.NEIN; } diff --git a/src/modules/person/api/personendatensatz.ts b/src/modules/person/api/personendatensatz.response.ts similarity index 72% rename from src/modules/person/api/personendatensatz.ts rename to src/modules/person/api/personendatensatz.response.ts index fccf386f0..6e671bada 100644 --- a/src/modules/person/api/personendatensatz.ts +++ b/src/modules/person/api/personendatensatz.response.ts @@ -3,13 +3,14 @@ import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { ValidateNested } from 'class-validator'; import { PersonResponse } from './person.response.js'; +import { PersonenkontextResponse } from './personenkontext.response.js'; -export class PersonenDatensatz { +export class PersonendatensatzResponse { @AutoMap(() => PersonResponse) @ValidateNested() @Type(() => PersonResponse) @ApiProperty({ name: 'person', type: PersonResponse, required: true }) public person!: PersonResponse; - // personKontext wird spaeter hier hinzugefuegt. + public personenkontexte!: PersonenkontextResponse[]; } diff --git a/src/modules/person/api/personenkontext-query.params.spec.ts b/src/modules/person/api/personenkontext-query.params.spec.ts new file mode 100644 index 000000000..14ca3b8c5 --- /dev/null +++ b/src/modules/person/api/personenkontext-query.params.spec.ts @@ -0,0 +1,30 @@ +import { plainToInstance } from 'class-transformer'; +import 'reflect-metadata'; +import { PersonenkontextQueryParams } from './personenkontext-query.params.js'; +import { SichtfreigabeType } from './personen-query.param.js'; +import { Personenstatus, Rolle } from '../domain/personenkontext.enums.js'; + +describe('PersonenkontextQueryParams', () => { + const referenceParams: PersonenkontextQueryParams = { + sichtfreigabe: SichtfreigabeType.JA, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + rolle: Rolle.LERNENDER, + }; + + it('should convert a plain object to a class of PersonenkontextQueryParams', () => { + const incomingParams: object = { + sichtfreigabe: SichtfreigabeType.JA, + personenstatus: Personenstatus.AKTIV, + referrer: 'referrer', + rolle: Rolle.LERNENDER, + }; + const mappedParams: PersonenkontextQueryParams = plainToInstance( + PersonenkontextQueryParams, + incomingParams, + {}, + ); + expect(mappedParams).toBeInstanceOf(PersonenkontextQueryParams); + expect(mappedParams).toEqual(referenceParams); + }); +}); diff --git a/src/modules/person/api/personenkontext-query.params.ts b/src/modules/person/api/personenkontext-query.params.ts new file mode 100644 index 000000000..59c256526 --- /dev/null +++ b/src/modules/person/api/personenkontext-query.params.ts @@ -0,0 +1,49 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { Personenstatus, Rolle } from '../domain/personenkontext.enums.js'; +import { SichtfreigabeType } from './personen-query.param.js'; + +export class PersonenkontextQueryParams { + @AutoMap() + @IsOptional() + @IsString() + @ApiProperty({ + name: 'referrer', + required: false, + nullable: true, + }) + public readonly referrer?: string; + + @AutoMap() + @IsOptional() + @IsEnum(Rolle) + @ApiProperty({ + name: 'rolle', + required: false, + nullable: true, + }) + public readonly rolle?: Rolle; + + @AutoMap() + @IsOptional() + @IsEnum(Personenstatus) + @ApiProperty({ + name: 'personenstatus', + required: false, + nullable: true, + }) + public readonly personenstatus?: Personenstatus; + + @AutoMap() + @IsOptional() + @IsEnum(SichtfreigabeType) + @ApiProperty({ + name: 'sichtfreigabe', + enum: SichtfreigabeType, + default: SichtfreigabeType.NEIN, + required: false, + nullable: true, + }) + public readonly sichtfreigabe: SichtfreigabeType = SichtfreigabeType.NEIN; +} diff --git a/src/modules/person/api/personenkontext.uc.spec.ts b/src/modules/person/api/personenkontext.uc.spec.ts index 8fa40b7f8..b6d42fcda 100644 --- a/src/modules/person/api/personenkontext.uc.spec.ts +++ b/src/modules/person/api/personenkontext.uc.spec.ts @@ -8,6 +8,13 @@ import { PersonApiMapperProfile } from './person-api.mapper.profile.js'; import { PersonenkontextUc } from './personenkontext.uc.js'; import { EntityCouldNotBeCreated } from '../../../shared/error/entity-could-not-be-created.error.js'; import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; +import { SichtfreigabeType } from './personen-query.param.js'; +import { Personenstatus, Rolle } from '../domain/personenkontext.enums.js'; +import { PersonenkontextResponse } from './personenkontext.response.js'; +import { faker } from '@faker-js/faker'; +import { DomainError } from '../../../shared/error/domain.error.js'; +import { EntityNotFoundError } from '../../../shared/error/entity-not-found.error.js'; describe('PersonenkontextUc', () => { let module: TestingModule; @@ -75,4 +82,46 @@ describe('PersonenkontextUc', () => { }); }); }); + + describe('findAll', () => { + describe('When searching for personenkontexte', () => { + it('should find all persons that match with query param', async () => { + const findPersonenkontextDto: FindPersonenkontextDto = { + personId: faker.string.uuid(), + referrer: 'referrer', + sichtfreigabe: SichtfreigabeType.NEIN, + personenstatus: Personenstatus.AKTIV, + rolle: Rolle.LERNENDER, + }; + + const firstPersonenkontext: PersonenkontextDo = DoFactory.createPersonenkontext(true); + const secondPersonenkontext: PersonenkontextDo = DoFactory.createPersonenkontext(true); + const personenkontexte: PersonenkontextDo[] = [firstPersonenkontext, secondPersonenkontext]; + personenkontextServiceMock.findAllPersonenkontexte.mockResolvedValue({ + ok: true, + value: personenkontexte, + }); + + const result: PersonenkontextResponse[] = await personenkontextUc.findAll(findPersonenkontextDto); + expect(result).toHaveLength(2); + }); + + it('should throw EntityNotFoundError when no matching persons are found', async () => { + const findPersonenkontextDto: FindPersonenkontextDto = { + personId: faker.string.uuid(), + referrer: 'referrer', + sichtfreigabe: SichtfreigabeType.NEIN, + personenstatus: Personenstatus.AKTIV, + rolle: Rolle.LERNENDER, + }; + + const emptyResult: Result[], DomainError> = { + ok: false, + error: new EntityNotFoundError('Personenkontext'), + }; + personenkontextServiceMock.findAllPersonenkontexte.mockResolvedValue(emptyResult); + await expect(personenkontextUc.findAll(findPersonenkontextDto)).rejects.toThrow(EntityNotFoundError); + }); + }); + }); }); diff --git a/src/modules/person/api/personenkontext.uc.ts b/src/modules/person/api/personenkontext.uc.ts index 5c3da4460..f49e03cc5 100644 --- a/src/modules/person/api/personenkontext.uc.ts +++ b/src/modules/person/api/personenkontext.uc.ts @@ -1,10 +1,13 @@ import { Mapper } from '@automapper/core'; import { getMapperToken } from '@automapper/nestjs'; import { Inject, Injectable } from '@nestjs/common'; +import { DomainError } from '../../../shared/error/domain.error.js'; import { PersonenkontextDo } from '../domain/personenkontext.do.js'; import { PersonenkontextService } from '../domain/personenkontext.service.js'; import { CreatePersonenkontextDto } from './create-personenkontext.dto.js'; import { CreatedPersonenkontextDto } from './created-personenkontext.dto.js'; +import { FindPersonenkontextDto } from './find-personenkontext.dto.js'; +import { PersonenkontextResponse } from './personenkontext.response.js'; @Injectable() export class PersonenkontextUc { @@ -29,4 +32,25 @@ export class PersonenkontextUc { } throw result.error; } + + // TODO refactor after EW-561 is done + public async findAll(findePersonenkontextDto: FindPersonenkontextDto): Promise { + const personenkontextDo: PersonenkontextDo = this.mapper.map( + findePersonenkontextDto, + FindPersonenkontextDto, + PersonenkontextDo, + ); + const result: Result[], DomainError> = + await this.personenkontextService.findAllPersonenkontexte(personenkontextDo); + + if (!result.ok) { + throw result.error; + } + + const personenkontexte: PersonenkontextResponse[] = result.value.map( + (personenkontext: PersonenkontextDo) => + this.mapper.map(personenkontext, PersonenkontextDo, PersonenkontextResponse), + ); + return personenkontexte; + } } diff --git a/src/modules/person/domain/personenkontext.service.spec.ts b/src/modules/person/domain/personenkontext.service.spec.ts index 5e8a8d115..48452e1c7 100644 --- a/src/modules/person/domain/personenkontext.service.spec.ts +++ b/src/modules/person/domain/personenkontext.service.spec.ts @@ -109,4 +109,42 @@ describe('PersonenkontextService', () => { }); }); }); + + describe('findAllPersonenkontexte', () => { + describe('When personenkontexte are found', () => { + it('should get all personenkontexte that match', async () => { + const personenkontext1: PersonenkontextDo = DoFactory.createPersonenkontext(false); + const personenkontext2: PersonenkontextDo = DoFactory.createPersonenkontext(false); + const personenkontexte: PersonenkontextDo[] = [ + personenkontext1 as unknown as PersonenkontextDo, + personenkontext2 as unknown as PersonenkontextDo, + ]; + personenkontextRepoMock.findAll.mockResolvedValue(personenkontexte); + mapperMock.map.mockReturnValue(personenkontexte as unknown as Dictionary); + const personenkontextDoWithQueryParam: PersonenkontextDo = + DoFactory.createPersonenkontext(false); + + const result: Result[], DomainError> = + await personenkontextService.findAllPersonenkontexte(personenkontextDoWithQueryParam); + expect(result).toEqual[], DomainError>>({ + ok: true, + value: personenkontexte, + }); + }); + }); + + describe('When no personenkontexte are found', () => { + it('should return a result with an empty array', async () => { + const personenkontext: PersonenkontextDo = DoFactory.createPersonenkontext(false); + personenkontextRepoMock.findAll.mockResolvedValue([]); + mapperMock.map.mockReturnValue(personenkontext as unknown as Dictionary); + const result: Result[], DomainError> = + await personenkontextService.findAllPersonenkontexte(personenkontext); + expect(result).toEqual[], DomainError>>({ + ok: true, + value: [], + }); + }); + }); + }); }); diff --git a/src/modules/person/domain/personenkontext.service.ts b/src/modules/person/domain/personenkontext.service.ts index 1bbab51f1..af868d73c 100644 --- a/src/modules/person/domain/personenkontext.service.ts +++ b/src/modules/person/domain/personenkontext.service.ts @@ -27,4 +27,12 @@ export class PersonenkontextService { } return { ok: false, error: new EntityCouldNotBeCreated(`Personenkontext`) }; } + + public async findAllPersonenkontexte( + personenkontextDo: PersonenkontextDo, + ): Promise[], DomainError>> { + const personenkontexte: PersonenkontextDo[] = await this.personenkontextRepo.findAll(personenkontextDo); + + return { ok: true, value: personenkontexte }; + } } diff --git a/src/modules/person/persistence/personenkontext.repo.spec.ts b/src/modules/person/persistence/personenkontext.repo.spec.ts index ba734fff2..3a469bf1d 100644 --- a/src/modules/person/persistence/personenkontext.repo.spec.ts +++ b/src/modules/person/persistence/personenkontext.repo.spec.ts @@ -9,6 +9,8 @@ import { PersonPersistenceMapperProfile } from './person-persistence.mapper.prof import { PersonEntity } from './person.entity.js'; import { PersonenkontextEntity } from './personenkontext.entity.js'; import { PersonenkontextRepo } from './personenkontext.repo.js'; +import { Personenstatus, Rolle } from '../domain/personenkontext.enums.js'; +import { faker } from '@faker-js/faker'; describe('PersonenkontextRepo', () => { let module: TestingModule; @@ -102,4 +104,52 @@ describe('PersonenkontextRepo', () => { }); }); }); + + describe('findAll', () => { + describe('When personenkontext for person exists', () => { + it('should find all personenkontexte for this person', async () => { + const props: Partial> = { + referrer: 'referrer', + personenstatus: Personenstatus.AKTIV, + rolle: Rolle.LERNENDER, + sichtfreigabe: false, + }; + const person1Id: string = faker.string.uuid(); + const personenkontextDo1: PersonenkontextDo = DoFactory.createPersonenkontext(false, { + ...props, + personId: person1Id, + }); + const personenkontextDo2: PersonenkontextDo = DoFactory.createPersonenkontext(false, props); + await em.persistAndFlush(mapper.map(personenkontextDo1, PersonenkontextDo, PersonenkontextEntity)); + await em.persistAndFlush(mapper.map(personenkontextDo2, PersonenkontextDo, PersonenkontextEntity)); + + const personenkontextDoFromQueryParam: PersonenkontextDo = DoFactory.createPersonenkontext( + false, + { + ...props, + personId: person1Id, + }, + ); + + const result: PersonenkontextDo[] = await sut.findAll(personenkontextDoFromQueryParam); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + await expect(em.find(PersonenkontextEntity, {})).resolves.toHaveLength(2); + }); + }); + + describe('When no personenkontext matches', () => { + it('should return an empty list', async () => { + const props: Partial> = {}; + const personenkontextDoFromQueryParam: PersonenkontextDo = DoFactory.createPersonenkontext( + false, + props, + ); + const result: PersonenkontextDo[] = await sut.findAll(personenkontextDoFromQueryParam); + expect(result).not.toBeNull(); + expect(result).toHaveLength(0); + await expect(em.find(PersonenkontextEntity, {})).resolves.toHaveLength(0); + }); + }); + }); }); diff --git a/src/modules/person/persistence/personenkontext.repo.ts b/src/modules/person/persistence/personenkontext.repo.ts index 002dacf04..4d1aa5ea1 100644 --- a/src/modules/person/persistence/personenkontext.repo.ts +++ b/src/modules/person/persistence/personenkontext.repo.ts @@ -17,6 +17,34 @@ export class PersonenkontextRepo { return this.create(personenkontextDo); } + // TODO refactor after EW-561 is done, use Scope + public async findAll(personenkontextDo: PersonenkontextDo): Promise[]> { + const query: Record = {}; + + query['personId'] = personenkontextDo.personId; + + if (personenkontextDo.referrer) { + query['referrer'] = personenkontextDo.referrer; + } + + if (personenkontextDo.rolle) { + query['rolle'] = personenkontextDo.rolle; + } + + if (personenkontextDo.personenstatus) { + query['personenstatus'] = personenkontextDo.personenstatus; + } + + if (personenkontextDo.sichtfreigabe !== undefined) { + query['sichtfreigabe'] = personenkontextDo.sichtfreigabe; + } + + const result: PersonenkontextEntity[] = await this.em.find(PersonenkontextEntity, query); + return result.map((person: PersonenkontextEntity) => + this.mapper.map(person, PersonenkontextEntity, PersonenkontextDo), + ); + } + private async create(personenkontextDo: PersonenkontextDo): Promise>> { const personenkontext: PersonenkontextEntity = this.mapper.map( personenkontextDo, diff --git a/src/modules/ui-backend/api/keycloak-exception-filter.ts b/src/modules/ui-backend/api/keycloak-exception-filter.ts new file mode 100644 index 000000000..f418f2f5f --- /dev/null +++ b/src/modules/ui-backend/api/keycloak-exception-filter.ts @@ -0,0 +1,6 @@ +import { Catch } from '@nestjs/common'; +import { KeycloakClientError } from '../../../shared/error/index.js'; +import { UiBackendExceptionFilter } from './ui-backend-exception-filter.js'; + +@Catch(KeycloakClientError) +export class KeyCloakExceptionFilter extends UiBackendExceptionFilter {} diff --git a/src/modules/ui-backend/api/login.controller.spec.ts b/src/modules/ui-backend/api/login.controller.spec.ts new file mode 100644 index 000000000..1a3fe3397 --- /dev/null +++ b/src/modules/ui-backend/api/login.controller.spec.ts @@ -0,0 +1,116 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LoginController } from './login.controller.js'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { LoginService } from '../domain/login.service.js'; +import { faker } from '@faker-js/faker'; +import { TokenSet } from 'openid-client'; +import { UserParams } from './user.params.js'; +import { KeycloakClientError, UserAuthenticationFailedError } from '../../../shared/error/index.js'; +import { NewLoginService } from '../domain/new-login.service.js'; + +describe('LoginController', () => { + let module: TestingModule; + let loginController: LoginController; + let loginServiceMock: DeepMocked; + let someServiceMock: DeepMocked; + let tokenSet: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [], + providers: [ + LoginController, + { + provide: LoginService, + useValue: createMock(), + }, + { + provide: NewLoginService, + useValue: createMock(), + }, + { + provide: TokenSet, + useValue: createMock(), + }, + ], + }).compile(); + loginController = module.get(LoginController); + loginServiceMock = module.get(LoginService); + someServiceMock = module.get(NewLoginService); + tokenSet = module.get(TokenSet); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(loginController).toBeDefined(); + }); + + describe('when getting result from service', () => { + it('should not throw', async () => { + const userParams: UserParams = { + username: faker.string.alpha(), + password: faker.string.alpha(), + }; + loginServiceMock.getTokenForUser.mockResolvedValue(tokenSet); + await expect(loginController.loginUser(userParams)).resolves.not.toThrow(); + expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1); + }); + }); + + describe('when getting KeyCloak-error from service', () => { + it('should throw', async () => { + const errorMsg: string = 'keycloak not available'; + const userParams: UserParams = { + username: faker.string.alpha(), + password: faker.string.alpha(), + }; + loginServiceMock.getTokenForUser.mockImplementation(() => { + throw new KeycloakClientError(errorMsg); + }); + await expect(loginController.loginUser(userParams)).rejects.toThrow(errorMsg); + expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1); + }); + }); + + describe('when getting User-authentication-failed-error from service', () => { + it('should throw', async () => { + const errorMsg: string = 'user could not be authenticated'; + const userParams: UserParams = { + username: faker.string.alpha(), + password: faker.string.alpha(), + }; + loginServiceMock.getTokenForUser.mockImplementation(() => { + throw new UserAuthenticationFailedError(errorMsg); + }); + await expect(loginController.loginUser(userParams)).rejects.toThrow(errorMsg); + expect(loginServiceMock.getTokenForUser).toHaveBeenCalledTimes(1); + }); + }); + + describe('when getting User-authentication-failed-error from service', () => { + it('should throw', async () => { + const userParams: UserParams = { + username: faker.string.alpha(), + password: faker.string.alpha(), + }; + someServiceMock.auth.mockResolvedValueOnce({ + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }); + await expect(loginController.loginUserResult(userParams)).resolves.toStrictEqual< + Result + >({ + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }); + expect(someServiceMock.auth).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/ui-backend/api/login.controller.ts b/src/modules/ui-backend/api/login.controller.ts new file mode 100644 index 000000000..43a005f11 --- /dev/null +++ b/src/modules/ui-backend/api/login.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, HttpStatus, Post, UseFilters } from '@nestjs/common'; +import { + ApiInternalServerErrorResponse, + ApiNotFoundResponse, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UserParams } from './user.params.js'; +import { LoginService } from '../domain/login.service.js'; +import { TokenSet } from 'openid-client'; +import { KeyCloakExceptionFilter } from './keycloak-exception-filter.js'; +import { UserAuthenticationFailedExceptionFilter } from './user-authentication-failed-exception-filter.js'; +import { NewLoginService } from '../domain/new-login.service.js'; +import { DomainError } from '../../../shared/error/index.js'; +import { Public } from 'nest-keycloak-connect'; + +@ApiTags('api/login') +@Controller({ path: 'login' }) +export class LoginController { + public constructor(private loginService: LoginService, private someService: NewLoginService) {} + + @Post() + @UseFilters( + new KeyCloakExceptionFilter(HttpStatus.SERVICE_UNAVAILABLE), + new UserAuthenticationFailedExceptionFilter(HttpStatus.NOT_FOUND), + ) + @ApiNotFoundResponse({ + description: 'USER_AUTHENTICATION_FAILED_ERROR: User could not be authenticated successfully.', + }) + @ApiInternalServerErrorResponse({ description: 'Internal server error while retrieving token.' }) + @ApiServiceUnavailableResponse({ description: 'KEYCLOAK_CLIENT_ERROR: KeyCloak service did not respond.' }) + @Public() + public async loginUser(@Body() params: UserParams): Promise { + return this.loginService.getTokenForUser(params.username, params.password); + } + + @Post('result') + @UseFilters( + new KeyCloakExceptionFilter(HttpStatus.SERVICE_UNAVAILABLE), + new UserAuthenticationFailedExceptionFilter(HttpStatus.NOT_FOUND), + ) + @ApiNotFoundResponse({ + description: 'USER_AUTHENTICATION_FAILED_ERROR: User could not be authenticated successfully.', + }) + @ApiInternalServerErrorResponse({ description: 'Internal server error while retrieving token.' }) + @Public() + public async loginUserResult(@Body() params: UserParams): Promise> { + return this.someService.auth(params.username, params.password); + } +} diff --git a/src/modules/ui-backend/api/ui-backend-exception-filter.spec.ts b/src/modules/ui-backend/api/ui-backend-exception-filter.spec.ts new file mode 100644 index 000000000..3b791582b --- /dev/null +++ b/src/modules/ui-backend/api/ui-backend-exception-filter.spec.ts @@ -0,0 +1,60 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UiBackendExceptionFilter } from './ui-backend-exception-filter.js'; +import { KeycloakClientError } from '../../../shared/error/index.js'; +import Mock = jest.Mock; +import { ArgumentsHost } from '@nestjs/common'; + +const mockJson: Mock = jest.fn(); +const mockUrl: Mock = jest.fn().mockImplementation(() => ({ + url: mockUrl, +})); +const mockStatus: Mock = jest.fn().mockImplementation(() => ({ + json: mockJson, +})); +const mockGetRequest: Mock = jest.fn().mockImplementation(() => ({ + url: mockUrl, +})); +const mockGetResponse: Mock = jest.fn().mockImplementation(() => ({ + status: mockStatus, +})); +const mockHttpArgumentsHost: Mock = jest.fn().mockImplementation(() => ({ + getResponse: mockGetResponse, + getRequest: mockGetRequest, +})); + +const mockArgumentsHost: ArgumentsHost = { + switchToHttp: mockHttpArgumentsHost, + getArgByIndex: jest.fn(), + getArgs: jest.fn(), + getType: jest.fn(), + switchToRpc: jest.fn(), + switchToWs: jest.fn(), +}; + +describe('System header validation service', () => { + let service: UiBackendExceptionFilter; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [UiBackendExceptionFilter], + }).compile(); + service = module.get>(UiBackendExceptionFilter); + }); + + describe('UI Backend exception filter tests', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('Http exception', () => { + service.catch(new KeycloakClientError(''), mockArgumentsHost); + expect(mockHttpArgumentsHost).toBeCalledTimes(1); + expect(mockHttpArgumentsHost).toBeCalledWith(); + expect(mockGetResponse).toBeCalledTimes(1); + expect(mockGetResponse).toBeCalledWith(); + expect(mockStatus).toBeCalledTimes(1); + expect(mockJson).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/modules/ui-backend/api/ui-backend-exception-filter.ts b/src/modules/ui-backend/api/ui-backend-exception-filter.ts new file mode 100644 index 000000000..374346c34 --- /dev/null +++ b/src/modules/ui-backend/api/ui-backend-exception-filter.ts @@ -0,0 +1,20 @@ +import { ExceptionFilter, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { DomainError } from '../../../shared/error/index.js'; +import { HttpArgumentsHost } from '@nestjs/common/interfaces'; + +export class UiBackendExceptionFilter implements ExceptionFilter { + public constructor(private httpStatusCode: HttpStatus) {} + + public catch(exception: R, host: ArgumentsHost): void { + const ctx: HttpArgumentsHost = host.switchToHttp(); + const response: Response = ctx.getResponse(); + const request: Request = ctx.getRequest(); + + response.status(this.httpStatusCode).json({ + code: exception.code, + message: exception.message, + path: request.url, + }); + } +} diff --git a/src/modules/ui-backend/api/user-authentication-failed-exception-filter.ts b/src/modules/ui-backend/api/user-authentication-failed-exception-filter.ts new file mode 100644 index 000000000..8a2ace05b --- /dev/null +++ b/src/modules/ui-backend/api/user-authentication-failed-exception-filter.ts @@ -0,0 +1,6 @@ +import { Catch } from '@nestjs/common'; +import { UserAuthenticationFailedError } from '../../../shared/error/index.js'; +import { UiBackendExceptionFilter } from './ui-backend-exception-filter.js'; + +@Catch(UserAuthenticationFailedError) +export class UserAuthenticationFailedExceptionFilter extends UiBackendExceptionFilter {} diff --git a/src/modules/ui-backend/api/user.params.ts b/src/modules/ui-backend/api/user.params.ts new file mode 100644 index 000000000..14ce5d5c6 --- /dev/null +++ b/src/modules/ui-backend/api/user.params.ts @@ -0,0 +1,15 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class UserParams { + @AutoMap() + @IsString() + @ApiProperty({ name: 'username', required: true }) + public readonly username!: string; + + @AutoMap() + @IsString() + @ApiProperty({ name: 'password', required: true }) + public readonly password!: string; +} diff --git a/src/modules/ui-backend/domain/login.service.spec.ts b/src/modules/ui-backend/domain/login.service.spec.ts new file mode 100644 index 000000000..07c0d29b7 --- /dev/null +++ b/src/modules/ui-backend/domain/login.service.spec.ts @@ -0,0 +1,59 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LoginService } from './login.service.js'; +import { errors, Issuer } from 'openid-client'; +import { createMock } from '@golevelup/ts-jest'; +import { KeycloakClientError, UserAuthenticationFailedError } from '../../../shared/error/index.js'; +import OPError = errors.OPError; +import { ConfigTestModule } from '../../../../test/utils/index.js'; + +const issuerDiscoverMock: jest.Mock = jest.fn(); +Issuer.discover = issuerDiscoverMock; + +describe('LoginService', () => { + let module: TestingModule; + let loginService: LoginService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule], + providers: [LoginService], + }).compile(); + loginService = module.get(LoginService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(loginService).toBeDefined(); + }); + + describe('should execute getTokenForUser', () => { + it('expect no exceptions when Keycloak is mocked', async () => { + issuerDiscoverMock.mockResolvedValueOnce(createMock(Issuer)); + await expect(loginService.getTokenForUser('u', 'p')).resolves.not.toThrow(); + expect(issuerDiscoverMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('should fail during execution of getTokenForUser', () => { + it('expect KeycloakClientError', async () => { + const expectedError: KeycloakClientError = new KeycloakClientError('KeyCloak service did not respond.'); + issuerDiscoverMock.mockRejectedValueOnce(expectedError); + await expect(loginService.getTokenForUser('u', 'p')).rejects.toThrow(KeycloakClientError); + expect(issuerDiscoverMock).toHaveBeenCalledTimes(1); + }); + + it('expect UserAuthenticationFailedError', async () => { + const expectedError: OPError = new OPError({ error: 'invalid_grant' }); + issuerDiscoverMock.mockRejectedValueOnce(expectedError); + await expect(loginService.getTokenForUser('u', 'p')).rejects.toThrow(UserAuthenticationFailedError); + expect(issuerDiscoverMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/ui-backend/domain/login.service.ts b/src/modules/ui-backend/domain/login.service.ts new file mode 100644 index 000000000..d71a58302 --- /dev/null +++ b/src/modules/ui-backend/domain/login.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { Client, errors, Issuer, TokenSet } from 'openid-client'; +import OPError = errors.OPError; +import { KeycloakClientError } from '../../../shared/error/index.js'; +import { UserAuthenticationFailedError } from '../../../shared/error/user-authentication-failed.error.js'; +import { KeycloakConfig } from '../../../shared/config/index.js'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class LoginService { + private kcConfig: KeycloakConfig; + + public constructor(private readonly config: ConfigService) { + this.kcConfig = this.config.getOrThrow('KEYCLOAK'); + } + + public async getTokenForUser(username: string, password: string): Promise { + try { + const keycloakIssuer: Issuer = await Issuer.discover( + this.kcConfig.BASE_URL + '/realms/' + this.kcConfig.REALM_NAME, + ); + const client: Client = new keycloakIssuer.Client({ + client_id: this.kcConfig.CLIENT_ID, + token_endpoint_auth_method: 'none', + }); + return await client.grant({ + grant_type: 'password', + username: username, + password: password, + }); + } catch (e) { + if (e instanceof OPError && e.error === 'invalid_grant') { + throw new UserAuthenticationFailedError('User could not be authenticated successfully.'); + } + throw new KeycloakClientError('KeyCloak service did not respond.'); + } + } +} diff --git a/src/modules/ui-backend/domain/new-login.service.spec.ts b/src/modules/ui-backend/domain/new-login.service.spec.ts new file mode 100644 index 000000000..d3db2faa6 --- /dev/null +++ b/src/modules/ui-backend/domain/new-login.service.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Issuer } from 'openid-client'; +import { NewLoginService } from './new-login.service.js'; +import { ConfigTestModule } from '../../../../test/utils/index.js'; +import { KeycloakAdminClient } from '@s3pweb/keycloak-admin-client-cjs'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { KeycloakClientError, UserAuthenticationFailedError } from '../../../shared/error/index.js'; + +const issuerDiscoverMock: jest.Mock = jest.fn(); +Issuer.discover = issuerDiscoverMock; + +describe('SomeService', () => { + let module: TestingModule; + let someService: NewLoginService; + let kcAdminClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigTestModule], + providers: [ + NewLoginService, + { + provide: KeycloakAdminClient, + useValue: createMock(), + }, + ], + }).compile(); + someService = module.get(NewLoginService); + kcAdminClient = module.get(KeycloakAdminClient); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('should execute getTokenForUser', () => { + it('expect no exceptions when Keycloak is mocked', async () => { + kcAdminClient.getAccessToken.mockResolvedValueOnce(Promise.resolve('thisIsATokenString')); + const result: Result = await someService.auth('test', 'pass'); + expect(result).toStrictEqual>({ + ok: true, + value: 'thisIsATokenString', + }); + await someService.auth('user', 'password'); + expect(kcAdminClient.auth).toHaveBeenCalledTimes(2); + }); + }); + + describe('should execute getTokenForUser', () => { + it('expect exception when auth() fails', async () => { + kcAdminClient.auth.mockRejectedValueOnce(KeycloakClientError); + const result: Result = await someService.auth('test', 'pass'); + expect(result).toStrictEqual>({ + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }); + expect(kcAdminClient.auth).toHaveBeenCalledTimes(1); + }); + + it('expect exception for accessToken === undefined', async () => { + kcAdminClient.auth.mockImplementationOnce(() => Promise.resolve()); + kcAdminClient.getAccessToken.mockResolvedValueOnce(Promise.resolve(undefined)); + const result: Result = await someService.auth('test', 'pass'); + expect(result).toStrictEqual>({ + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }); + expect(kcAdminClient.auth).toHaveBeenCalledTimes(1); + }); + + it('expect exception when getAccessToken throws exception', async () => { + kcAdminClient.auth.mockImplementationOnce(() => Promise.resolve()); + kcAdminClient.getAccessToken.mockRejectedValueOnce(Promise.resolve('test')); + const result: Result = await someService.auth('test', 'pass'); + expect(result).toStrictEqual>({ + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }); + expect(kcAdminClient.auth).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/ui-backend/domain/new-login.service.ts b/src/modules/ui-backend/domain/new-login.service.ts new file mode 100644 index 000000000..f2c5ec4af --- /dev/null +++ b/src/modules/ui-backend/domain/new-login.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Credentials, KeycloakAdminClient } from '@s3pweb/keycloak-admin-client-cjs'; +import { ConfigService } from '@nestjs/config'; +import { KeycloakConfig } from '../../../shared/config/index.js'; +import { DomainError, UserAuthenticationFailedError } from '../../../shared/error/index.js'; + +@Injectable() +export class NewLoginService { + private kcConfig: KeycloakConfig; + + public constructor(private readonly kcAdminClient: KeycloakAdminClient, private readonly config: ConfigService) { + this.kcConfig = this.config.getOrThrow('KEYCLOAK'); + this.kcAdminClient.setConfig({ + baseUrl: this.kcConfig.BASE_URL, + realmName: this.kcConfig.REALM_NAME, + }); + } + + public async auth(username: string, password: string): Promise> { + try { + const credentials: Credentials = { + grantType: 'password', + clientId: this.kcConfig.CLIENT_ID, + username: username, + password: password, + }; + await this.kcAdminClient.auth(credentials); + const accessToken: string | undefined = await this.kcAdminClient.getAccessToken(); + if (accessToken !== undefined) { + return { ok: true, value: accessToken }; + } else { + // kcAdminClient will throw an exception if credentials are wrong, not return undefined + return { + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }; + } + } catch (err) { + return { + ok: false, + error: new UserAuthenticationFailedError('User could not be authenticated successfully.'), + }; + } + } +} diff --git a/src/modules/ui-backend/ui-backend-api.module.ts b/src/modules/ui-backend/ui-backend-api.module.ts new file mode 100644 index 000000000..4aff43dc6 --- /dev/null +++ b/src/modules/ui-backend/ui-backend-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { LoginController } from './api/login.controller.js'; +import { LoginService } from './domain/login.service.js'; +import { NewLoginService } from './domain/new-login.service.js'; +import { KeycloakAdminClient } from '@s3pweb/keycloak-admin-client-cjs'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + imports: [], + providers: [KeycloakAdminClient, ConfigService, LoginService, NewLoginService], + controllers: [LoginController], +}) +export class UiBackendApiModule {} diff --git a/src/server/server.module.ts b/src/server/server.module.ts index 824f995f9..a8a633e94 100644 --- a/src/server/server.module.ts +++ b/src/server/server.module.ts @@ -13,6 +13,7 @@ import { OrganisationApiModule } from '../modules/organisation/organisation-api. import { AuthGuard, KeycloakConnectModule, ResourceGuard, RoleGuard } from 'nest-keycloak-connect'; import { APP_GUARD } from '@nestjs/core'; import { HealthModule } from '../modules/health/health.module.js'; +import { UiBackendApiModule } from '../modules/ui-backend/ui-backend-api.module.js'; @Module({ imports: [ @@ -53,7 +54,7 @@ import { HealthModule } from '../modules/health/health.module.js'; authServerUrl: keycloakConfig.BASE_URL, realm: keycloakConfig.REALM_NAME, clientId: keycloakConfig.CLIENT_ID, - secret: keycloakConfig.SECRET, + secret: keycloakConfig.ADMIN_SECRET, }; }, inject: [ConfigService], @@ -62,6 +63,7 @@ import { HealthModule } from '../modules/health/health.module.js'; OrganisationApiModule, KeycloakAdministrationModule, HealthModule, + UiBackendApiModule, ], providers: [ { diff --git a/src/shared/config/config.loader.spec.ts b/src/shared/config/config.loader.spec.ts index 470d9f3f0..1a23d4b5d 100644 --- a/src/shared/config/config.loader.spec.ts +++ b/src/shared/config/config.loader.spec.ts @@ -39,14 +39,16 @@ describe('configloader', () => { }, KEYCLOAK: { BASE_URL: 'localhost:8080', - CLIENT_ID: 'admin-cli', - REALM_NAME: 'master', + ADMIN_CLIENT_ID: 'admin-cli', + ADMIN_REALM_NAME: 'master', + REALM_NAME: 'schulportal', + CLIENT_ID: 'schulportal', }, }; const secrets: DeepPartial = { DB: { SECRET: 'SuperSecretSecret' }, - KEYCLOAK: { SECRET: 'ClientSecret' }, + KEYCLOAK: { ADMIN_SECRET: 'ClientSecret' }, }; beforeAll(() => { @@ -81,8 +83,10 @@ describe('configloader', () => { }, KEYCLOAK: { BASE_URL: '', - CLIENT_ID: '', + ADMIN_CLIENT_ID: '', + ADMIN_REALM_NAME: '', REALM_NAME: '', + CLIENT_ID: '', }, }; diff --git a/src/shared/config/keycloak.config.ts b/src/shared/config/keycloak.config.ts index c74b1a737..07911a5ce 100644 --- a/src/shared/config/keycloak.config.ts +++ b/src/shared/config/keycloak.config.ts @@ -7,13 +7,21 @@ export class KeycloakConfig { @IsString() @IsNotEmpty() - public readonly REALM_NAME!: string; + public readonly ADMIN_REALM_NAME!: string; @IsString() @IsNotEmpty() - public readonly CLIENT_ID!: string; + public readonly ADMIN_CLIENT_ID!: string; + + @IsString() + @IsNotEmpty() + public readonly ADMIN_SECRET!: string; @IsString() @IsNotEmpty() - public readonly SECRET!: string; + public readonly REALM_NAME!: string; + + @IsString() + @IsNotEmpty() + public readonly CLIENT_ID!: string; } diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts index 9d0526111..ce9b8a0aa 100644 --- a/src/shared/error/index.ts +++ b/src/shared/error/index.ts @@ -4,3 +4,4 @@ export * from './keycloak-client.error.js'; export * from './mapping.error.js'; export * from './person-already-exists.error.js'; export * from './entity-not-found.error.js'; +export * from './user-authentication-failed.error.js'; diff --git a/src/shared/error/user-authentication-failed.error.ts b/src/shared/error/user-authentication-failed.error.ts new file mode 100644 index 000000000..9156e10d0 --- /dev/null +++ b/src/shared/error/user-authentication-failed.error.ts @@ -0,0 +1,7 @@ +import { DomainError } from './domain.error.js'; + +export class UserAuthenticationFailedError extends DomainError { + public constructor(message: string, details?: unknown[] | Record) { + super(message, 'USER_AUTHENTICATION_FAILED_ERROR', details); + } +}