diff options
62 files changed, 2498 insertions, 578 deletions
@@ -33,6 +33,15 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc ## Konfiguration +### Breaking Changes nach Version 0.10 + +In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt: + +* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Nutzen Sie hier, wie unter [_Integration eines eigenen Root CA + Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben, das Einbinden eigener Zertifikate. +* `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC` +* `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC` + ### Pseudonymisierung der Patienten-ID Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet. @@ -64,9 +73,12 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * ~~`APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`~~: **Veraltet** - Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss. + **Wird in nach Version 0.10 entfernt** -Der Konfigurationsparameter `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION` sollte nicht mehr verwendet werden und wird in einer kommenden Version entfernt. -Stattdessen sollte das Root Zertifikat wie unter [_Integration eines eigenen Root CA Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben eingebunden werden. +Der Konfigurationsparameter `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION` sollte nicht mehr verwendet werden und wird nach +Version 0.10 entfernt. +Stattdessen sollte das Root Zertifikat wie unter [_Integration eines eigenen Root CA +Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben eingebunden werden. ### Anmeldung mit einem Passwort @@ -157,6 +169,8 @@ https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt. +Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen. + ### Transformation von Werten In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht, @@ -183,15 +197,17 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: * `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` +* `APP_REST_USERNAME`: Basic-Auth-Benutzername für bwHC-Backend +* `APP_REST_PASSWORD`: Basic-Auth-Passwort für bwHC-Backend #### Kafka-Topics Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird: * `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen. - Ersetzt in einer kommenden Version `APP_KAFKA_TOPIC`. + Ersetzt ~~`APP_KAFKA_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response". - Ersetzt in einer kommenden Version `APP_KAFKA_RESPONSE_TOPIC`. + Ersetzt ~~`APP_KAFKA_RESPONSE_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group". * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste diff --git a/build.gradle.kts b/build.gradle.kts index 011ade4..4e6499b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,23 +1,26 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.bundling.BootBuildImage plugins { war - id("org.springframework.boot") version "3.2.11" - id("io.spring.dependency-management") version "1.1.5" + id("org.springframework.boot") version "3.3.5" + id("io.spring.dependency-management") version "1.1.6" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" + jacoco } group = "dev.dnpm" -version = "0.9-SNAPSHOT" +version = "0.10.0-SNAPSHOT" var versions = mapOf( "bwhc-dto-java" to "0.3.0", "hapi-fhir" to "6.10.5", - "httpclient5" to "5.2.3", + "commons-compress" to "1.26.2", "mockito-kotlin" to "5.3.1", + "archunit" to "1.3.0", // Webjars "echarts" to "5.4.3", "htmx.org" to "1.9.12" @@ -62,13 +65,14 @@ dependencies { implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.springframework.kafka:spring-kafka") + implementation("org.flywaydb:flyway-database-postgresql") implementation("org.flywaydb:flyway-mysql") implementation("commons-codec:commons-codec") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") - implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}") + implementation("org.apache.httpcomponents.client5:httpclient5") implementation("com.jayway.jsonpath:json-path") implementation("org.webjars:webjars-locator:0.52") implementation("org.webjars.npm:echarts:${versions["echarts"]}") @@ -85,14 +89,17 @@ dependencies { testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}") integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:postgresql") + integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}") + integrationTestImplementation("net.sourceforge.htmlunit:htmlunit") + integrationTestImplementation("org.springframework:spring-webflux") // Override dependency version from org.testcontainers:junit-jupiter - CVE-2024-26308, CVE-2024-25710 - integrationTestImplementation("org.apache.commons:commons-compress:1.26.1") + integrationTestImplementation("org.apache.commons:commons-compress:${versions["commons-compress"]}") } tasks.withType<KotlinCompile> { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "21" + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + jvmTarget.set(JvmTarget.JVM_21) } } @@ -112,8 +119,22 @@ task<Test>("integrationTest") { shouldRunAfter("test") } +tasks.register("allTests") { + dependsOn(tasks.withType<Test>()) +} + +tasks.jacocoTestReport { + dependsOn("allTests") + + executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec")) + + reports { + xml.required = true + } +} + tasks.named<BootBuildImage>("bootBuildImage") { - imageName.set("ghcr.io/ccc-mf/etl-processor") + imageName.set("ghcr.io/pcvolkmer/etl-processor") // Binding for CA Certs bindings.set(listOf( diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 4641ca6..2180786 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -18,6 +18,8 @@ services: APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID} APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC} APP_REST_URI: ${DNPM_BWHC_REST_URI} + APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME} + APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD} APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER} APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} diff --git a/deploy/env-sample.env b/deploy/env-sample.env index 04a3f8f..9c06341 100644 --- a/deploy/env-sample.env +++ b/deploy/env-sample.env @@ -28,6 +28,8 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB ## TARGET SYSTEMS CONFIG # in case of direct access to bwhc enter endpoint url here DNPM_BWHC_REST_URI= +DNPM_BWHC_REST_USERNAME= +DNPM_BWHC_REST_PASSWORD= # produce mtb files to this topic - values 'false' disabling kafka processing DNPM_KAFKA_TOPIC=false diff --git a/dev-compose.yml b/dev-compose.yml index a162fcc..e2dfdb6 100644 --- a/dev-compose.yml +++ b/dev-compose.yml @@ -17,8 +17,9 @@ services: KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + ## Use AKHQ as Kafka web frontend akhq: - image: tchiotludo/akhq:0.21.0 + image: tchiotludo/akhq:0.25.0 environment: AKHQ_CONFIGURATION: | akhq: @@ -32,6 +33,8 @@ services: ports: - "8084:8080" + +## For use with MariaDB mariadb: image: mariadb:10 ports: @@ -42,6 +45,7 @@ services: MARIADB_PASSWORD: dev MARIADB_ROOT_PASSWORD: dev +## For use with Postgres # postgres: # image: postgres:alpine # ports: diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 509c4a2..20db9ad 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt new file mode 100644 index 0000000..308d0cc --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt @@ -0,0 +1,73 @@ +package dev.dnpm.etl.processor + +import com.tngtech.archunit.core.domain.JavaClasses +import com.tngtech.archunit.core.importer.ClassFileImporter +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.repository.Repository + +class EtlProcessorArchTest { + + private lateinit var noTestClasses: JavaClasses + + @BeforeEach + fun setUp() { + this.noTestClasses = ClassFileImporter() + .withImportOption { !(it.contains("/test/") || it.contains("/integrationTest/")) } + .importPackages("dev.dnpm.etl.processor") + } + + @Test + fun noClassesInInputPackageShouldDependOnMonitoringPackage() { + val rule = noClasses() + .that() + .resideInAPackage("..input") + .should().dependOnClassesThat() + .resideInAnyPackage("..monitoring") + + rule.check(noTestClasses) + } + + @Test + fun noClassesInInputPackageShouldDependOnRepositories() { + val rule = noClasses() + .that() + .resideInAPackage("..input") + .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository") + + rule.check(noTestClasses) + } + + @Test + fun noClassesInOutputPackageShouldDependOnRepositories() { + val rule = noClasses() + .that() + .resideInAPackage("..output") + .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository") + + rule.check(noTestClasses) + } + + @Test + fun noClassesInWebPackageShouldDependOnRepositories() { + val rule = noClasses() + .that() + .resideInAPackage("..web") + .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository") + + rule.check(noTestClasses) + } + + @Test + fun repositoryClassNamesShouldEndWithRepository() { + val rule = classes() + .that() + .areInterfaces().and().areAssignableTo(Repository::class.java) + .should().haveSimpleNameEndingWith("Repository") + + rule.check(noTestClasses) + } + +}
\ No newline at end of file diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt index 262aca0..c7454ed 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt @@ -27,8 +27,8 @@ import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.services.RequestProcessor -import dev.dnpm.etl.processor.services.TokenRepository -import dev.dnpm.etl.processor.services.TokenService +import dev.dnpm.etl.processor.security.TokenRepository +import dev.dnpm.etl.processor.security.TokenService import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt new file mode 100644 index 0000000..6ca420f --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt @@ -0,0 +1,30 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor + +import org.mockito.ArgumentMatchers + +@Suppress("UNCHECKED_CAST") +inline fun <reified T> anyValueClass(): T { + val unboxedClass = T::class.java.declaredFields.first().type + return ArgumentMatchers.any(unboxedClass as Class<T>) + ?: T::class.java.getDeclaredMethod("box-impl", unboxedClass) + .invoke(null, null) as T +}
\ No newline at end of file diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index f1586d0..670020f 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -21,13 +21,15 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import dev.dnpm.etl.processor.security.TokenRepository +import dev.dnpm.etl.processor.security.UserRoleRepository import dev.dnpm.etl.processor.services.RequestProcessor -import dev.dnpm.etl.processor.services.TokenRepository import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.ArgumentMatchers.anyString import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.never @@ -37,6 +39,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration @@ -92,6 +95,19 @@ class MtbFileRestControllerTest { } @Test + fun testShouldGrantPermissionToSendMtbFileToAdminUser() { + mockMvc.post("/mtbfile") { + with(user("onkostarserver").roles("ADMIN")) + contentType = MediaType.APPLICATION_JSON + content = ObjectMapper().writeValueAsString(mtbFile) + }.andExpect { + status { isAccepted() } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test fun testShouldDenyPermissionToSendMtbFile() { mockMvc.post("/mtbfile") { with(anonymous()) @@ -105,6 +121,19 @@ class MtbFileRestControllerTest { } @Test + fun testShouldDenyPermissionToSendMtbFileForUser() { + mockMvc.post("/mtbfile") { + with(user("fakeuser").roles("USER")) + contentType = MediaType.APPLICATION_JSON + content = ObjectMapper().writeValueAsString(mtbFile) + }.andExpect { + status { isForbidden() } + } + + verify(requestProcessor, never()).processMtbFile(any()) + } + + @Test fun testShouldGrantPermissionToDeletePatientData() { mockMvc.delete("/mtbfile/12345678") { with(user("onkostarserver").roles("MTBFILE")) @@ -112,7 +141,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processDeletion(anyString()) + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) } @Test @@ -123,7 +152,46 @@ class MtbFileRestControllerTest { status { isUnauthorized() } } - verify(requestProcessor, never()).processDeletion(anyString()) + verify(requestProcessor, never()).processDeletion(anyValueClass()) + } + + @Nested + @MockBean(UserRoleRepository::class, ClientRegistrationRepository::class) + @TestPropertySource( + properties = [ + "app.pseudonymize.generator=BUILDIN", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret", + "app.security.enable-tokens=true", + "app.security.enable-oidc=true" + ] + ) + inner class WithOidcEnabled { + @Test + fun testShouldGrantPermissionToSendMtbFileToAdminUser() { + mockMvc.post("/mtbfile") { + with(user("onkostarserver").roles("ADMIN")) + contentType = MediaType.APPLICATION_JSON + content = ObjectMapper().writeValueAsString(mtbFile) + }.andExpect { + status { isAccepted() } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test + fun testShouldGrantPermissionToSendMtbFileToUser() { + mockMvc.post("/mtbfile") { + with(user("onkostarserver").roles("USER")) + contentType = MediaType.APPLICATION_JSON + content = ObjectMapper().writeValueAsString(mtbFile) + }.andExpect { + status { isAccepted() } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } } companion object { diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt new file mode 100644 index 0000000..bef124c --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt @@ -0,0 +1,75 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.monitoring + +import dev.dnpm.etl.processor.* +import dev.dnpm.etl.processor.output.MtbFileSender +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.transaction.annotation.Transactional +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant + +@Testcontainers +@ExtendWith(SpringExtension::class) +@DataJdbcTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Transactional +@MockBean(MtbFileSender::class) +@TestPropertySource( + properties = [ + "app.pseudonymize.generator=buildin", + "app.rest.uri=http://example.com" + ] +) +class RequestRepositoryTest : AbstractTestcontainerTest() { + + private lateinit var requestRepository: RequestRepository + + @BeforeEach + fun setUp( + @Autowired requestRepository: RequestRepository + ) { + this.requestRepository = requestRepository + } + + @Test + fun shouldSaveRequest() { + val request = Request( + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-07-07T00:00:00Z") + ) + + requestRepository.save(request) + } + +}
\ No newline at end of file diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt new file mode 100644 index 0000000..da0c55c --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt @@ -0,0 +1,136 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.pseudonym + +import dev.dnpm.etl.processor.config.GPasConfigProperties +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.retry.policy.SimpleRetryPolicy +import org.springframework.retry.support.RetryTemplateBuilder +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withException +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.web.client.RestTemplate +import java.io.IOException + +class GpasPseudonymGeneratorTest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + private lateinit var generator: GpasPseudonymGenerator + private lateinit var restTemplate: RestTemplate + + @BeforeEach + fun setup() { + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + val gPasConfigProperties = GPasConfigProperties( + "http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", + "test", + null, + null, + null + ) + + this.restTemplate = RestTemplate() + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.generator = GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate) + } + + @Test + fun shouldReturnExpectedPseudonym() { + this.mockRestServiceServer.expect { + method(HttpMethod.POST) + requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + }.andRespond { + withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890")) + .createResponse(it) + } + + assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890") + } + + @Test + fun shouldThrowExceptionIfGpasNotAvailable() { + this.mockRestServiceServer.expect { + method(HttpMethod.POST) + requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + }.andRespond { + withException(IOException("Simulated IO error")).createResponse(it) + } + + assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") } + } + + @Test + fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() { + this.mockRestServiceServer.expect { + method(HttpMethod.POST) + requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + }.andRespond { + withStatus(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + .createResponse(it) + } + + assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") } + } + + companion object { + + fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "pseudonym", + "part": [ + { + "name": "original", + "valueIdentifier": { + "system": "https://ths-greifswald.de/gpas", + "value": "$original" + } + }, + { + "name": "target", + "valueIdentifier": { + "system": "https://ths-greifswald.de/gpas", + "value": "$target" + } + }, + { + "name": "pseudonym", + "valueIdentifier": { + "system": "https://ths-greifswald.de/gpas", + "value": "$pseudonym" + } + } + ] + } + ] + }""".trimIndent() + + } +}
\ No newline at end of file diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt index 88a3a08..47ac301 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt @@ -19,7 +19,7 @@ package dev.dnpm.etl.processor.services -import dev.dnpm.etl.processor.AbstractTestcontainerTest +import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus @@ -37,7 +37,6 @@ import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.transaction.annotation.Transactional import org.testcontainers.junit.jupiter.Testcontainers import java.time.Instant -import java.util.* @Testcontainers @ExtendWith(SpringExtension::class) @@ -66,7 +65,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() { @Test fun shouldResultInEmptyRequestList() { - val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901") + val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM) assertThat(actual).isEmpty() } @@ -76,33 +75,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() { this.requestRepository.saveAll( listOf( Request( - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-07-07T02:00:00Z") + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-07-07T02:00:00Z") ), // Should be ignored - wrong patient ID --> Request( - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678902", - pid = "P2", - fingerprint = "0123456789abcdef2", - type = RequestType.MTB_FILE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-08-08T00:00:00Z") + randomRequestId(), + PatientPseudonym("TEST_12345678902"), + PatientId("P2"), + Fingerprint("0123456789abcdef2"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-08-08T00:00:00Z") ), // <-- Request( - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P2", - fingerprint = "0123456789abcdee1", - type = RequestType.DELETE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P2"), + Fingerprint("0123456789abcdee1"), + RequestType.DELETE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) ) ) @@ -112,18 +111,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() { fun shouldResultInSortedRequestList() { setupTestData() - val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901") + val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM) assertThat(actual).hasSize(2) - assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1") - assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1") + assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1")) + assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1")) } @Test fun shouldReturnDeleteRequestAsLastRequest() { setupTestData() - val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901") + val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM) assertThat(actual).isTrue() } @@ -132,10 +131,14 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() { fun shouldReturnLastMtbFileRequest() { setupTestData() - val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901") + val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM) assertThat(actual).isNotNull - assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1") + assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1")) + } + + companion object { + val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901") } }
\ No newline at end of file diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt index 7fc0121..af4650d 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt @@ -19,32 +19,51 @@ package dev.dnpm.etl.processor.web +import com.gargoylesoftware.htmlunit.WebClient +import com.gargoylesoftware.htmlunit.html.HtmlPage import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration -import dev.dnpm.etl.processor.monitoring.ConnectionCheckService +import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult +import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.pseudonym.Generator +import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.services.RequestProcessor -import dev.dnpm.etl.processor.services.TokenRepository +import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.TransformationService +import dev.dnpm.etl.processor.security.UserRoleService +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.anyString import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.http.MediaType.TEXT_EVENT_STREAM +import org.springframework.security.test.context.support.WithMockUser import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringExtension -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.* +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder +import org.springframework.web.context.WebApplicationContext import reactor.core.publisher.Sinks +import reactor.test.StepVerifier +import java.time.Instant abstract class MockSink : Sinks.Many<Boolean> @@ -59,44 +78,48 @@ abstract class MockSink : Sinks.Many<Boolean> ) @TestPropertySource( properties = [ - "app.pseudonymize.generator=BUILDIN", - "app.security.admin-user=admin", - "app.security.admin-password={noop}very-secret", - "app.security.enable-tokens=true" + "app.pseudonymize.generator=BUILDIN" ] ) @MockBean(name = "configsUpdateProducer", classes = [MockSink::class]) @MockBean( Generator::class, MtbFileSender::class, - ConnectionCheckService::class, RequestProcessor::class, TransformationService::class, - TokenRepository::class, - RestConnectionCheckService::class + GPasConnectionCheckService::class, + RestConnectionCheckService::class, ) class ConfigControllerTest { private lateinit var mockMvc: MockMvc + private lateinit var webClient: WebClient private lateinit var requestProcessor: RequestProcessor + private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> @BeforeEach fun setup( @Autowired mockMvc: MockMvc, - @Autowired requestProcessor: RequestProcessor + @Autowired requestProcessor: RequestProcessor, + @Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) { this.mockMvc = mockMvc + this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build() this.requestProcessor = requestProcessor + this.connectionCheckUpdateProducer = connectionCheckUpdateProducer + + webClient.options.isThrowExceptionOnScriptError = false } @Test - fun testShouldShowConfigPageIfLoggedIn() { + fun testShouldRequestConfigPageIfLoggedIn() { mockMvc.get("/configs") { with(user("admin").roles("ADMIN")) accept(MediaType.TEXT_HTML) }.andExpect { status { isOk() } + view { name("configs") } } } @@ -113,4 +136,228 @@ class ConfigControllerTest { } } + @Nested + @TestPropertySource( + properties = [ + "app.security.enable-tokens=true", + "app.security.admin-user=admin" + ] + ) + @MockBean( + TokenService::class + ) + inner class WithTokensEnabled { + private lateinit var tokenService: TokenService + + @BeforeEach + fun setup( + @Autowired tokenService: TokenService + ) { + webClient.options.isThrowExceptionOnScriptError = false + + this.tokenService = tokenService + } + + @Test + fun testShouldSaveNewToken() { + mockMvc.post("/configs/tokens") { + with(user("admin").roles("ADMIN")) + accept(MediaType.TEXT_HTML) + contentType = MediaType.APPLICATION_FORM_URLENCODED + content = "name=Testtoken" + }.andExpect { + status { is2xxSuccessful() } + view { name("configs/tokens") } + } + + val captor = argumentCaptor<String>() + verify(tokenService, times(1)).addToken(captor.capture()) + + assertThat(captor.firstValue).isEqualTo("Testtoken") + } + + @Test + fun testShouldNotSaveTokenWithExstingName() { + whenever(tokenService.addToken(anyString())).thenReturn(Result.failure(RuntimeException("Testfailure"))) + + mockMvc.post("/configs/tokens") { + with(user("admin").roles("ADMIN")) + accept(MediaType.TEXT_HTML) + contentType = MediaType.APPLICATION_FORM_URLENCODED + content = "name=Testtoken" + }.andExpect { + status { is2xxSuccessful() } + view { name("configs/tokens") } + } + + val captor = argumentCaptor<String>() + verify(tokenService, times(1)).addToken(captor.capture()) + + assertThat(captor.firstValue).isEqualTo("Testtoken") + } + + @Test + fun testShouldDeleteToken() { + mockMvc.delete("/configs/tokens/42") { + with(user("admin").roles("ADMIN")) + accept(MediaType.TEXT_HTML) + }.andExpect { + status { is2xxSuccessful() } + view { name("configs/tokens") } + } + + val captor = argumentCaptor<Long>() + verify(tokenService, times(1)).deleteToken(captor.capture()) + + assertThat(captor.firstValue).isEqualTo(42) + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldRenderConfigPageWithTokens() { + val page = webClient.getPage<HtmlPage>("http://localhost/configs") + assertThat( + page.getElementById("tokens") + ).isNotNull + } + } + + @Nested + @TestPropertySource( + properties = [ + "app.security.enable-tokens=false" + ] + ) + inner class WithTokensDisabled { + @BeforeEach + fun setup() { + webClient.options.isThrowExceptionOnScriptError = false + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldRenderConfigPageWithoutTokens() { + val page = webClient.getPage<HtmlPage>("http://localhost/configs") + assertThat( + page.getElementById("tokens") + ).isNull() + } + } + + @Nested + @TestPropertySource( + properties = [ + "app.security.enable-tokens=false", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret" + ] + ) + @MockBean( + UserRoleService::class + ) + inner class WithUserRolesEnabled { + private lateinit var userRoleService: UserRoleService + + @BeforeEach + fun setup( + @Autowired userRoleService: UserRoleService + ) { + webClient.options.isThrowExceptionOnScriptError = false + + this.userRoleService = userRoleService + } + + @Test + fun testShouldDeleteUserRole() { + mockMvc.delete("/configs/userroles/42") { + with(user("admin").roles("ADMIN")) + accept(MediaType.TEXT_HTML) + }.andExpect { + status { is2xxSuccessful() } + view { name("configs/userroles") } + } + + val captor = argumentCaptor<Long>() + verify(userRoleService, times(1)).deleteUserRole(captor.capture()) + + assertThat(captor.firstValue).isEqualTo(42) + } + + @Test + fun testShouldUpdateUserRole() { + mockMvc.put("/configs/userroles/42") { + with(user("admin").roles("ADMIN")) + accept(MediaType.TEXT_HTML) + contentType = MediaType.APPLICATION_FORM_URLENCODED + content = "role=ADMIN" + }.andExpect { + status { is2xxSuccessful() } + view { name("configs/userroles") } + } + + val idCaptor = argumentCaptor<Long>() + val roleCaptor = argumentCaptor<Role>() + verify(userRoleService, times(1)).updateUserRole(idCaptor.capture(), roleCaptor.capture()) + + assertThat(idCaptor.firstValue).isEqualTo(42) + assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN) + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldRenderConfigPageWithUserRoles() { + val page = webClient.getPage<HtmlPage>("http://localhost/configs") + assertThat( + page.getElementById("userroles") + ).isNotNull + } + } + + @Nested + inner class WithUserRolesDisabled { + @BeforeEach + fun setup() { + webClient.options.isThrowExceptionOnScriptError = false + } + + @Test + fun testShouldRenderConfigPageWithoutUserRoles() { + val page = webClient.getPage<HtmlPage>("http://localhost/configs") + assertThat( + page.getElementById("userroles") + ).isNull() + } + } + + @Nested + inner class SseTest { + private lateinit var webClient: WebTestClient + + @BeforeEach + fun setup( + applicationContext: WebApplicationContext, + ) { + this.webClient = MockMvcWebTestClient + .bindToApplicationContext(applicationContext).build() + } + + @Test + fun testShouldRequestSSE() { + val expectedEvent = ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now()) + + connectionCheckUpdateProducer.tryEmitNext(expectedEvent) + connectionCheckUpdateProducer.emitComplete { _, _ -> true } + + val result = webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM).exchange() + .expectStatus().isOk() + .expectHeader().contentType(TEXT_EVENT_STREAM) + .returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java) + + StepVerifier.create(result.responseBody) + .expectNext(expectedEvent) + .expectComplete() + .verify() + } + } + } diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt new file mode 100644 index 0000000..82835b4 --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt @@ -0,0 +1,287 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.web + +import com.gargoylesoftware.htmlunit.WebClient +import com.gargoylesoftware.htmlunit.html.HtmlPage +import dev.dnpm.etl.processor.* +import dev.dnpm.etl.processor.config.AppConfiguration +import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import dev.dnpm.etl.processor.monitoring.Report +import dev.dnpm.etl.processor.monitoring.Request +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.RequestType +import dev.dnpm.etl.processor.services.RequestService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder +import java.io.IOException +import java.time.Instant +import java.util.* + +@WebMvcTest(controllers = [HomeController::class]) +@ExtendWith(value = [MockitoExtension::class, SpringExtension::class]) +@ContextConfiguration( + classes = [ + HomeController::class, + AppConfiguration::class, + AppSecurityConfiguration::class + ] +) +@TestPropertySource( + properties = [ + "app.pseudonymize.generator=BUILDIN", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret" + ] +) +@MockBean( + RequestService::class +) +class HomeControllerTest { + + private lateinit var mockMvc: MockMvc + private lateinit var webClient: WebClient + + @BeforeEach + fun setup( + @Autowired mockMvc: MockMvc, + @Autowired requestService: RequestService + ) { + this.mockMvc = mockMvc + this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build() + + whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty()) + } + + @Test + fun testShouldRequestHomePage() { + mockMvc.get("/").andExpect { + status { isOk() } + view { name("index") } + } + } + + @Nested + inner class WithRequests { + + private lateinit var requestService: RequestService + + @BeforeEach + fun setup( + @Autowired requestService: RequestService + ) { + this.requestService = requestService + } + + @Test + fun testShouldShowHomePage() { + whenever(requestService.findAll(any<Pageable>())).thenReturn( + PageImpl( + listOf( + Request( + 2L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("ashdkasdh"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS + ), + Request( + 1L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("asdasdasd"), + RequestType.MTB_FILE, + RequestStatus.ERROR + ) + ) + ) + ) + + val page = webClient.getPage<HtmlPage>("http://localhost/") + assertThat(page.querySelectorAll("tbody tr")).hasSize(2) + assertThat(page.querySelectorAll("div.notification.info")).isEmpty() + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldShowRequestDetails() { + val requestId = randomRequestId() + + whenever(requestService.findByUuid(anyValueClass())).thenReturn( + Optional.of( + Request( + 2L, + requestId, + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("ashdkasdh"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.now(), + Report("Test") + ) + ) + ) + + val page = webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}") + assertThat(page.querySelectorAll("tbody tr")).hasSize(1) + assertThat(page.querySelectorAll("div.notification.info")).isEmpty() + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldShowPatientDetails() { + whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn( + PageImpl( + listOf( + Request( + 2L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("ashdkasdh"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS + ), + Request( + 1L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("asdasdasd"), + RequestType.MTB_FILE, + RequestStatus.ERROR + ) + ) + ) + ) + + val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1") + assertThat(page.querySelectorAll("tbody tr")).hasSize(2) + assertThat(page.querySelectorAll("div.notification.info")).isEmpty() + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldShowPatientPseudonym() { + whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn( + PageImpl( + listOf( + Request( + 2L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("ashdkasdh"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS + ), + Request( + 1L, + randomRequestId(), + PatientPseudonym("PSEUDO1"), + PatientId("PATIENT1"), + Fingerprint("asdasdasd"), + RequestType.MTB_FILE, + RequestStatus.ERROR + ) + ) + ) + ) + + val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1") + assertThat(page.querySelectorAll("h2 > span")).hasSize(1) + assertThat(page.querySelectorAll("h2 > span").first().textContent).isEqualTo("PSEUDO1") + } + + } + + @Nested + inner class WithoutRequests { + + private lateinit var requestService: RequestService + + @BeforeEach + fun setup( + @Autowired requestService: RequestService + ) { + this.requestService = requestService + + whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty()) + } + + @Test + fun testShouldShowHomePage() { + val page = webClient.getPage<HtmlPage>("http://localhost/") + assertThat(page.querySelectorAll("tbody tr")).isEmpty() + assertThat(page.querySelectorAll("div.notification.info")).hasSize(1) + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldThrowNotFoundExceptionForUnknownReport() { + val requestId = randomRequestId() + + whenever(requestService.findByUuid(anyValueClass())).thenReturn( + Optional.empty() + ) + + assertThrows<IOException> { + webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}") + }.also { + assertThat(it).hasRootCauseInstanceOf(NotFoundException::class.java) + } + } + + @Test + @WithMockUser(username = "admin", roles = ["ADMIN"]) + fun testShouldShowEmptyPatientDetails() { + whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(Page.empty()) + + val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1") + assertThat(page.querySelectorAll("tbody tr")).isEmpty() + assertThat(page.querySelectorAll("div.notification.info")).hasSize(1) + } + } + +} diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt new file mode 100644 index 0000000..0471543 --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt @@ -0,0 +1,88 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.web + +import com.gargoylesoftware.htmlunit.WebClient +import com.gargoylesoftware.htmlunit.html.HtmlPage +import dev.dnpm.etl.processor.config.AppConfiguration +import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import dev.dnpm.etl.processor.security.TokenService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder + +@WebMvcTest(controllers = [LoginController::class]) +@ExtendWith(value = [MockitoExtension::class, SpringExtension::class]) +@ContextConfiguration( + classes = [ + LoginController::class, + AppConfiguration::class, + AppSecurityConfiguration::class + ] +) +@TestPropertySource( + properties = [ + "app.pseudonymize.generator=BUILDIN", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret", + "app.security.enable-tokens=true" + ] +) +@MockBean( + TokenService::class, +) +class LoginControllerTest { + + private lateinit var mockMvc: MockMvc + private lateinit var webClient: WebClient + + @BeforeEach + fun setup(@Autowired mockMvc: MockMvc) { + this.mockMvc = mockMvc + this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build() + } + + @Test + fun testShouldRequestLoginPage() { + mockMvc.get("/login").andExpect { + status { isOk() } + view { name("login") } + } + } + + @Test + fun testShouldShowLoginForm() { + val page = webClient.getPage<HtmlPage>("http://localhost/login") + assertThat( + page.getElementsByTagName("main").first().firstElementChild.getAttribute("class") + ).isEqualTo("login-form") + } +} diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt new file mode 100644 index 0000000..424a0e3 --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt @@ -0,0 +1,73 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.web + +import com.gargoylesoftware.htmlunit.WebClient +import dev.dnpm.etl.processor.config.AppConfiguration +import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder + +@WebMvcTest(controllers = [StatisticsController::class]) +@ExtendWith(value = [MockitoExtension::class, SpringExtension::class]) +@ContextConfiguration( + classes = [ + StatisticsController::class, + AppConfiguration::class, + AppSecurityConfiguration::class + ] +) +@TestPropertySource( + properties = [ + "app.pseudonymize.generator=BUILDIN", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret" + ] +) +class StatisticsControllerTest { + + private lateinit var mockMvc: MockMvc + private lateinit var webClient: WebClient + + @BeforeEach + fun setup(@Autowired mockMvc: MockMvc) { + this.mockMvc = mockMvc + this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build() + } + + @Test + fun testShouldRequestLoginPage() { + mockMvc.get("/statistics").andExpect { + status { isOk() } + view { name("statistics") } + } + } + +} diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt new file mode 100644 index 0000000..b9a1338 --- /dev/null +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt @@ -0,0 +1,312 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.web + +import dev.dnpm.etl.processor.Fingerprint +import dev.dnpm.etl.processor.PatientId +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.AppConfiguration +import dev.dnpm.etl.processor.config.AppSecurityConfiguration +import dev.dnpm.etl.processor.monitoring.CountedState +import dev.dnpm.etl.processor.monitoring.Request +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.RequestType +import dev.dnpm.etl.processor.randomRequestId +import dev.dnpm.etl.processor.services.RequestService +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasSize +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType.TEXT_EVENT_STREAM +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.client.MockMvcWebTestClient +import org.springframework.test.web.servlet.get +import org.springframework.web.context.WebApplicationContext +import reactor.core.publisher.Sinks +import reactor.test.StepVerifier +import java.time.Instant +import java.time.temporal.ChronoUnit + + +@WebMvcTest(controllers = [StatisticsRestController::class]) +@ExtendWith(value = [MockitoExtension::class, SpringExtension::class]) +@ContextConfiguration( + classes = [ + StatisticsRestController::class, + AppConfiguration::class, + AppSecurityConfiguration::class + ] +) +@TestPropertySource( + properties = [ + "app.pseudonymize.generator=BUILDIN", + "app.security.admin-user=admin", + "app.security.admin-password={noop}very-secret" + ] +) +@MockBean( + RequestService::class +) +class StatisticsRestControllerTest { + + private lateinit var mockMvc: MockMvc + + private lateinit var statisticsUpdateProducer: Sinks.Many<Any> + private lateinit var requestService: RequestService + + @BeforeEach + fun setup( + @Autowired mockMvc: MockMvc, + @Autowired statisticsUpdateProducer: Sinks.Many<Any>, + @Autowired requestService: RequestService + ) { + this.mockMvc = mockMvc + this.statisticsUpdateProducer = statisticsUpdateProducer + this.requestService = requestService + } + + @Nested + inner class RequestStatesTest { + @Test + fun testShouldRequestStatesForMtbFiles() { + doAnswer { _ -> + listOf( + CountedState(42, RequestStatus.WARNING), + CountedState(1, RequestStatus.UNKNOWN) + ) + }.whenever(requestService).countStates() + + mockMvc.get("/statistics/requeststates").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(2)) + jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name)) + jsonPath("$[0].value", equalTo(42)) + jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name)) + jsonPath("$[1].value", equalTo(1)) + } + } + } + + @Test + fun testShouldRequestStatesForDeletes() { + doAnswer { _ -> + listOf( + CountedState(42, RequestStatus.SUCCESS), + CountedState(1, RequestStatus.ERROR) + ) + }.whenever(requestService).countDeleteStates() + + mockMvc.get("/statistics/requeststates?delete=true").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(2)) + jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name)) + jsonPath("$[0].value", equalTo(42)) + jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name)) + jsonPath("$[1].value", equalTo(1)) + } + } + } + } + + @Nested + inner class PatientRequestStatesTest { + @Test + fun testShouldRequestPatientStatesForMtbFiles() { + doAnswer { _ -> + listOf( + CountedState(42, RequestStatus.WARNING), + CountedState(1, RequestStatus.UNKNOWN) + ) + }.whenever(requestService).findPatientUniqueStates() + + mockMvc.get("/statistics/requestpatientstates").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(2)) + jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name)) + jsonPath("$[0].value", equalTo(42)) + jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name)) + jsonPath("$[1].value", equalTo(1)) + } + } + } + + @Test + fun testShouldRequestPatientStatesForDeletes() { + doAnswer { _ -> + listOf( + CountedState(42, RequestStatus.SUCCESS), + CountedState(1, RequestStatus.ERROR) + ) + }.whenever(requestService).findPatientUniqueDeleteStates() + + mockMvc.get("/statistics/requestpatientstates?delete=true").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(2)) + jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name)) + jsonPath("$[0].value", equalTo(42)) + jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name)) + jsonPath("$[1].value", equalTo(1)) + } + } + } + } + + @Nested + inner class LastMonthStatesTest { + + @BeforeEach + fun setup() { + doAnswer { _ -> + listOf( + Request( + 1, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + ), + Request( + 2, + randomRequestId(), + PatientPseudonym("TEST_12345678902"), + PatientId("P2"), + Fingerprint("0123456789abcdef2"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + ), + Request( + 3, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P2"), + Fingerprint("0123456789abcdee1"), + RequestType.DELETE, + RequestStatus.ERROR, + Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + ), + Request( + 4, + randomRequestId(), + PatientPseudonym("TEST_12345678902"), + PatientId("P2"), + Fingerprint("0123456789abcdef2"), + RequestType.MTB_FILE, + RequestStatus.DUPLICATION, + Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + ), + Request( + 5, + randomRequestId(), + PatientPseudonym("TEST_12345678902"), + PatientId("P2"), + Fingerprint("0123456789abcdef2"), + RequestType.DELETE, + RequestStatus.UNKNOWN, + Instant.now().truncatedTo(ChronoUnit.DAYS) + ), + ) + }.whenever(requestService).findAll() + } + + @Test + fun testShouldRequestLastMonthForMtbFiles() { + mockMvc.get("/statistics/requestslastmonth").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(31)) + }.also { + jsonPath("$[28].nameValues.error", equalTo(0)) + jsonPath("$[28].nameValues.warning", equalTo(1)) + jsonPath("$[28].nameValues.success", equalTo(1)) + jsonPath("$[28].nameValues.duplication", equalTo(0)) + jsonPath("$[28].nameValues.unknown", equalTo(0)) + jsonPath("$[29].nameValues.error", equalTo(0)) + jsonPath("$[29].nameValues.warning", equalTo(0)) + jsonPath("$[29].nameValues.success", equalTo(0)) + jsonPath("$[29].nameValues.duplication", equalTo(1)) + jsonPath("$[29].nameValues.unknown", equalTo(0)) + } + } + } + + @Test + fun testShouldRequestLastMonthForDeletes() { + mockMvc.get("/statistics/requestslastmonth?delete=true").andExpect { + status { isOk() }.also { + jsonPath("$", hasSize<Int>(31)) + }.also { + jsonPath("$[29].nameValues.error", equalTo(1)) + jsonPath("$[29].nameValues.warning", equalTo(0)) + jsonPath("$[29].nameValues.success", equalTo(0)) + jsonPath("$[29].nameValues.duplication", equalTo(0)) + jsonPath("$[29].nameValues.unknown", equalTo(0)) + jsonPath("$[30].nameValues.error", equalTo(0)) + jsonPath("$[30].nameValues.warning", equalTo(0)) + jsonPath("$[30].nameValues.success", equalTo(0)) + jsonPath("$[30].nameValues.duplication", equalTo(0)) + jsonPath("$[30].nameValues.unknown", equalTo(1)) + } + } + } + } + + @Nested + inner class SseTest { + private lateinit var webClient: WebTestClient + + @BeforeEach + fun setup( + applicationContext: WebApplicationContext, + ) { + this.webClient = MockMvcWebTestClient + .bindToApplicationContext(applicationContext).build() + } + + @Test + fun testShouldRequestSSE() { + statisticsUpdateProducer.emitComplete { _, _ -> true } + + val result = webClient.get().uri("http://localhost/statistics/events").accept(TEXT_EVENT_STREAM).exchange() + .expectStatus().isOk() + .expectHeader().contentType(TEXT_EVENT_STREAM) + .returnResult(String::class.java) + + StepVerifier.create(result.responseBody) + .expectComplete() + .verify() + } + } + +} diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java index 446bd16..77caa77 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java @@ -23,41 +23,17 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.IParser; import dev.dnpm.etl.processor.config.GPasConfigProperties; import org.apache.commons.lang3.StringUtils; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; -import org.apache.hc.client5.http.socket.ConnectionSocketFactory; -import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; -import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; -import org.apache.hc.core5.http.config.Registry; -import org.apache.hc.core5.http.config.RegistryBuilder; import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.StringType; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.retry.support.RetryTemplate; import org.springframework.web.client.RestTemplate; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManagerFactory; -import java.io.BufferedInputStream; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.Base64; - public class GpasPseudonymGenerator implements Generator { private final static FhirContext r4Context = FhirContext.forR4(); @@ -69,27 +45,13 @@ public class GpasPseudonymGenerator implements Generator { private final RestTemplate restTemplate; - private SSLContext customSslContext; - - public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate) { + public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) { this.retryTemplate = retryTemplate; - this.restTemplate = getRestTemplete(); - + this.restTemplate = restTemplate; this.gPasUrl = gpasCfg.getUri(); this.psnTargetDomain = gpasCfg.getTarget(); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); - try { - if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) { - customSslContext = getSslContext(gpasCfg.getSslCaLocation()); - log.warn(String.format("%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.", - this.getClass().getName(), gpasCfg.getSslCaLocation())); - } - } catch (IOException | KeyManagementException | KeyStoreException | CertificateException | - NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - log.debug(String.format("%s has been initialized", this.getClass().getName())); } @@ -99,7 +61,7 @@ public class GpasPseudonymGenerator implements Generator { var gPasRequestBody = getGpasRequestBody(id); var responseEntity = getGpasPseudonym(gPasRequestBody); var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() - .parseResource(responseEntity.getBody()); + .parseResource(responseEntity.getBody()); return unwrapPseudonym(gPasPseudonymResult); } @@ -113,9 +75,9 @@ public class GpasPseudonymGenerator implements Generator { } final var identifier = (Identifier) parameters.get().getPart().stream() - .filter(a -> a.getName().equals("pseudonym")) - .findFirst() - .orElseGet(ParametersParameterComponent::new).getValue(); + .filter(a -> a.getName().equals("pseudonym")) + .findFirst() + .orElseGet(ParametersParameterComponent::new).getValue(); // pseudonym return sanitizeValue(identifier.getValue()); @@ -144,8 +106,8 @@ public class GpasPseudonymGenerator implements Generator { try { responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, - String.class)); + ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, + String.class)); if (responseEntity.getStatusCode().is2xxSuccessful()) { log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); @@ -157,16 +119,16 @@ public class GpasPseudonymGenerator implements Generator { return responseEntity; } catch (Exception unexpected) { throw new PseudonymRequestFailed( - "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); + "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); } } protected String getGpasRequestBody(String id) { var requestParameters = new Parameters(); requestParameters.addParameter().setName("target") - .setValue(new StringType().setValue(psnTargetDomain)); + .setValue(new StringType().setValue(psnTargetDomain)); requestParameters.addParameter().setName("original") - .setValue(new StringType().setValue(id)); + .setValue(new StringType().setValue(id)); final IParser iParser = r4Context.newJsonParser(); return iParser.encodeResourceToString(requestParameters); } @@ -180,67 +142,7 @@ public class GpasPseudonymGenerator implements Generator { return headers; } - String authHeader = gPasUserName + ":" + gPasPassword; - byte[] authHeaderBytes = authHeader.getBytes(); - byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes); - String encodedAuthHeader = new String(encodedAuthHeaderBytes); - - if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) { - headers.set("Authorization", "Basic " + encodedAuthHeader); - } - + headers.setBasicAuth(gPasUserName, gPasPassword); return headers; } - - /** - * Read SSL root certificate and return SSLContext - * - * @param certificateLocation file location to root certificate (PEM) - * @return initialized SSLContext - * @throws IOException file cannot be read - * @throws CertificateException in case we have an invalid certificate of type X.509 - * @throws KeyStoreException keystore cannot be initialized - * @throws NoSuchAlgorithmException missing trust manager algorithmus - * @throws KeyManagementException key management failed at init SSLContext - */ - @Nullable - protected SSLContext getSslContext(String certificateLocation) - throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException { - - KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); - - FileInputStream fis = new FileInputStream(certificateLocation); - X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509") - .generateCertificate(new BufferedInputStream(fis)); - - ks.load(null, null); - ks.setCertificateEntry(Integer.toString(1), ca); - - TrustManagerFactory tmf = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(ks); - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, tmf.getTrustManagers(), null); - - return sslContext; - } - - protected RestTemplate getRestTemplete() { - if (customSslContext == null) { - return new RestTemplate(); - } - final var sslsf = new SSLConnectionSocketFactory(customSslContext); - final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create() - .register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build(); - - final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager( - socketFactoryRegistry); - final CloseableHttpClient httpClient = HttpClients.custom() - .setConnectionManager(connectionManager).build(); - - final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory( - httpClient); - return new RestTemplate(requestFactory); - } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index d951c60..dd7e461 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -69,6 +69,8 @@ data class GPasConfigProperties( @ConfigurationProperties(RestTargetProperties.NAME) data class RestTargetProperties( val uri: String?, + val username: String?, + val password: String?, ) { companion object { const val NAME = "app.rest" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt index 0ae2c2f..5fc1120 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt @@ -20,21 +20,32 @@ package dev.dnpm.etl.processor.config import com.fasterxml.jackson.databind.ObjectMapper -import dev.dnpm.etl.processor.monitoring.* +import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult +import dev.dnpm.etl.processor.monitoring.ConnectionCheckService +import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService +import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.PseudonymizeService -import dev.dnpm.etl.processor.services.TokenRepository -import dev.dnpm.etl.processor.services.TokenService +import dev.dnpm.etl.processor.security.TokenRepository +import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.TransformationService +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager +import org.apache.hc.client5.http.socket.ConnectionSocketFactory +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory +import org.apache.hc.core5.http.config.RegistryBuilder import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory import org.springframework.retry.RetryCallback import org.springframework.retry.RetryContext import org.springframework.retry.RetryListener @@ -46,6 +57,13 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.web.client.RestTemplate import reactor.core.publisher.Sinks +import java.io.BufferedInputStream +import java.io.FileInputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -70,8 +88,20 @@ class AppConfiguration { @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") @Bean - fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator { - return GpasPseudonymGenerator(configProperties, retryTemplate) + fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { + try { + if (!configProperties.sslCaLocation.isNullOrBlank()) { + return GpasPseudonymGenerator( + configProperties, + retryTemplate, + createCustomGpasRestTemplate(configProperties) + ) + } + } catch (e: Exception) { + throw RuntimeException(e) + } + + return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) } @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true) @@ -83,8 +113,80 @@ class AppConfiguration { @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS") @ConditionalOnMissingBean @Bean - fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator { - return GpasPseudonymGenerator(configProperties, retryTemplate) + fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { + try { + if (!configProperties.sslCaLocation.isNullOrBlank()) { + return GpasPseudonymGenerator( + configProperties, + retryTemplate, + createCustomGpasRestTemplate(configProperties) + ) + } + } catch (e: Exception) { + throw RuntimeException(e) + } + + return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) + } + + private fun createCustomGpasRestTemplate(configProperties: GPasConfigProperties): RestTemplate { + fun getSslContext(certificateLocation: String): SSLContext? { + val ks = KeyStore.getInstance(KeyStore.getDefaultType()) + + val fis = FileInputStream(certificateLocation) + val ca = CertificateFactory.getInstance("X.509") + .generateCertificate(BufferedInputStream(fis)) as X509Certificate + + ks.load(null, null) + ks.setCertificateEntry(1.toString(), ca) + + val tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm() + ) + tmf.init(ks) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, tmf.trustManagers, null) + + return sslContext + } + + fun getCustomRestTemplate(customSslContext: SSLContext): RestTemplate { + val sslsf = SSLConnectionSocketFactory(customSslContext) + val socketFactoryRegistry = RegistryBuilder.create<ConnectionSocketFactory>() + .register("https", sslsf).register("http", PlainConnectionSocketFactory()).build() + + val connectionManager = BasicHttpClientConnectionManager( + socketFactoryRegistry + ) + val httpClient = HttpClients.custom() + .setConnectionManager(connectionManager).build() + + val requestFactory = HttpComponentsClientHttpRequestFactory( + httpClient + ) + return RestTemplate(requestFactory) + } + + try { + if (!configProperties.sslCaLocation.isNullOrBlank()) { + val customSslContext = getSslContext(configProperties.sslCaLocation) + logger.warn( + String.format( + "%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.", + this.javaClass.name, configProperties.sslCaLocation + ) + ) + + if (customSslContext != null) { + return getCustomRestTemplate(customSslContext) + } + } + } catch (e: Exception) { + throw RuntimeException(e) + } + + throw RuntimeException("Custom SSL configuration for gPAS not usable") } @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN") @@ -173,5 +275,9 @@ class AppConfiguration { return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer) } + @Bean + fun jdbcConfiguration(): AbstractJdbcConfiguration { + return AppJdbcConfiguration() + } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt new file mode 100644 index 0000000..898982c --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt @@ -0,0 +1,25 @@ +package dev.dnpm.etl.processor.config + +import dev.dnpm.etl.processor.Fingerprint +import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter +import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration + +@Configuration +class AppJdbcConfiguration : AbstractJdbcConfiguration() { + override fun userConverters(): MutableList<*> { + return mutableListOf(StringToFingerprintConverter(), FingerprintToStringConverter()) + } +} + +class StringToFingerprintConverter : Converter<String, Fingerprint> { + override fun convert(source: String): Fingerprint { + return Fingerprint(source) + } +} + +class FingerprintToStringConverter : Converter<Fingerprint, String> { + override fun convert(source: Fingerprint): String { + return source.value + } +}
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt index c377555..6b063bd 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt @@ -21,7 +21,7 @@ package dev.dnpm.etl.processor.config import dev.dnpm.etl.processor.security.UserRole import dev.dnpm.etl.processor.security.UserRoleRepository -import dev.dnpm.etl.processor.services.UserRoleService +import dev.dnpm.etl.processor.security.UserRoleService import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.EnableConfigurationProperties @@ -89,7 +89,7 @@ class AppSecurityConfiguration( http { authorizeRequests { authorize("/configs/**", hasRole("ADMIN")) - authorize("/mtbfile/**", hasAnyRole("MTBFILE")) + authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER")) authorize("/report/**", hasAnyRole("ADMIN", "USER")) authorize("*.css", permitAll) authorize("*.ico", permitAll) @@ -147,7 +147,7 @@ class AppSecurityConfiguration( http { authorizeRequests { authorize("/configs/**", hasRole("ADMIN")) - authorize("/mtbfile/**", hasAnyRole("MTBFILE")) + authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN")) authorize("/report/**", hasRole("ADMIN")) authorize(anyRequest, permitAll) } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt index de901ce..2aff8cb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt @@ -22,6 +22,8 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.PatientId +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory @@ -35,11 +37,12 @@ class KafkaInputListener( override fun onMessage(data: ConsumerRecord<String, String>) { val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java) + val patientId = PatientId(mtbFile.patient.id) val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull() val requestId = if (null != firstRequestIdHeader) { - String(firstRequestIdHeader.value()) + RequestId(String(firstRequestIdHeader.value())) } else { - "" + RequestId("") } if (mtbFile.consent.status == Consent.Status.ACTIVE) { @@ -52,9 +55,9 @@ class KafkaInputListener( } else { logger.debug("Accepted MTB File and process deletion") if (requestId.isBlank()) { - requestProcessor.processDeletion(mtbFile.patient.id) + requestProcessor.processDeletion(patientId) } else { - requestProcessor.processDeletion(mtbFile.patient.id, requestId) + requestProcessor.processDeletion(patientId, requestId) } } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index 8259288..9e282c2 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.input import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.services.RequestProcessor import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity @@ -46,7 +47,8 @@ class MtbFileRestController( requestProcessor.processMtbFile(mtbFile) } else { logger.debug("Accepted MTB File and process deletion") - requestProcessor.processDeletion(mtbFile.patient.id) + val patientId = PatientId(mtbFile.patient.id) + requestProcessor.processDeletion(patientId) } return ResponseEntity.accepted().build() } @@ -54,7 +56,7 @@ class MtbFileRestController( @DeleteMapping(path = ["{patientId}"]) fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> { logger.debug("Accepted patient ID to process deletion") - requestProcessor.processDeletion(patientId) + requestProcessor.processDeletion(PatientId(patientId)) return ResponseEntity.accepted().build() } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt index 81ad922..e70da3e 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -26,22 +26,18 @@ import jakarta.annotation.PostConstruct import org.apache.kafka.clients.consumer.Consumer import org.apache.kafka.common.errors.TimeoutException import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.RequestEntity +import org.springframework.http.* import org.springframework.scheduling.annotation.Scheduled import org.springframework.web.client.RestTemplate import org.springframework.web.util.UriComponentsBuilder import reactor.core.publisher.Sinks +import java.time.Instant import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration interface ConnectionCheckService { - fun connectionAvailable(): Boolean + fun connectionAvailable(): ConnectionCheckResult } @@ -51,9 +47,27 @@ sealed class ConnectionCheckResult { abstract val available: Boolean - data class KafkaConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult() - data class RestConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult() - data class GPasConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult() + abstract val timestamp: Instant + + abstract val lastChange: Instant + + data class KafkaConnectionCheckResult( + override val available: Boolean, + override val timestamp: Instant, + override val lastChange: Instant + ) : ConnectionCheckResult() + + data class RestConnectionCheckResult( + override val available: Boolean, + override val timestamp: Instant, + override val lastChange: Instant + ) : ConnectionCheckResult() + + data class GPasConnectionCheckResult( + override val available: Boolean, + override val timestamp: Instant, + override val lastChange: Instant + ) : ConnectionCheckResult() } class KafkaConnectionCheckService( @@ -62,25 +76,33 @@ class KafkaConnectionCheckService( private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : OutputConnectionCheckService { - private var connectionAvailable: Boolean = false - + private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now()) @PostConstruct @Scheduled(cron = "0 * * * * *") fun check() { - connectionAvailable = try { - null != consumer.listTopics(5.seconds.toJavaDuration()) + result = try { + val available = null != consumer.listTopics(5.seconds.toJavaDuration()) + ConnectionCheckResult.KafkaConnectionCheckResult( + available, + Instant.now(), + if (result.available == available) { result.lastChange } else { Instant.now() } + ) } catch (e: TimeoutException) { - false + ConnectionCheckResult.KafkaConnectionCheckResult( + false, + Instant.now(), + if (!result.available) { result.lastChange } else { Instant.now() } + ) } connectionCheckUpdateProducer.emitNext( - ConnectionCheckResult.KafkaConnectionCheckResult(connectionAvailable), + result, Sinks.EmitFailureHandler.FAIL_FAST ) } - override fun connectionAvailable(): Boolean { - return this.connectionAvailable + override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult { + return this.result } } @@ -92,27 +114,37 @@ class RestConnectionCheckService( private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : OutputConnectionCheckService { - private var connectionAvailable: Boolean = false + private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now()) @PostConstruct @Scheduled(cron = "0 * * * * *") fun check() { - connectionAvailable = try { - restTemplate.getForEntity( + result = try { + val available = restTemplate.getForEntity( restTargetProperties.uri?.replace("/etl/api", "").toString(), String::class.java ).statusCode == HttpStatus.OK + + ConnectionCheckResult.RestConnectionCheckResult( + available, + Instant.now(), + if (result.available == available) { result.lastChange } else { Instant.now() } + ) } catch (e: Exception) { - false + ConnectionCheckResult.RestConnectionCheckResult( + false, + Instant.now(), + if (!result.available) { result.lastChange } else { Instant.now() } + ) } connectionCheckUpdateProducer.emitNext( - ConnectionCheckResult.RestConnectionCheckResult(connectionAvailable), + result, Sinks.EmitFailureHandler.FAIL_FAST ) } - override fun connectionAvailable(): Boolean { - return this.connectionAvailable + override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult { + return this.result } } @@ -123,40 +155,48 @@ class GPasConnectionCheckService( private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> ) : ConnectionCheckService { - private var connectionAvailable: Boolean = false + private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now()) @PostConstruct @Scheduled(cron = "0 * * * * *") fun check() { - connectionAvailable = try { + result = try { val uri = UriComponentsBuilder.fromUriString( - gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/\$pseudonymize").toString() - ) - .queryParam("target", gPasConfigProperties.target) - .queryParam("original", "???") - .build().toUri() + gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString() + ).build().toUri() val headers = HttpHeaders() headers.contentType = MediaType.APPLICATION_JSON if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) { headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password) } - restTemplate.exchange( + + val available = restTemplate.exchange( uri, HttpMethod.GET, HttpEntity<Void>(headers), Void::class.java ).statusCode == HttpStatus.OK + + ConnectionCheckResult.GPasConnectionCheckResult( + available, + Instant.now(), + if (result.available == available) { result.lastChange } else { Instant.now() } + ) } catch (e: Exception) { - false + ConnectionCheckResult.GPasConnectionCheckResult( + false, + Instant.now(), + if (!result.available) { result.lastChange } else { Instant.now() } + ) } connectionCheckUpdateProducer.emitNext( - ConnectionCheckResult.GPasConnectionCheckResult(connectionAvailable), + result, Sinks.EmitFailureHandler.FAIL_FAST ) } - override fun connectionAvailable(): Boolean { - return this.connectionAvailable + override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult { + return this.result } }
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt index 97ecd05..062f749 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt @@ -19,6 +19,7 @@ package dev.dnpm.etl.processor.monitoring +import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonParseException @@ -54,7 +55,7 @@ class ReportService( private data class DataQualityReport(val issues: List<Issue>) @JsonIgnoreProperties(ignoreUnknown = true) - data class Issue(val severity: Severity, val message: String) + data class Issue(val severity: Severity, @JsonAlias("details") val message: String) enum class Severity(@JsonValue val value: String) { FATAL("fatal"), diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt index 028b4a3..36c9705 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt @@ -19,10 +19,12 @@ package dev.dnpm.etl.processor.monitoring +import dev.dnpm.etl.processor.* import org.springframework.data.annotation.Id import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jdbc.repository.query.Query +import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Embedded import org.springframework.data.relational.core.mapping.Table import org.springframework.data.repository.CrudRepository @@ -30,26 +32,48 @@ import org.springframework.data.repository.PagingAndSortingRepository import java.time.Instant import java.util.* -typealias RequestId = UUID - @Table("request") data class Request( @Id val id: Long? = null, - val uuid: String = RequestId.randomUUID().toString(), - val patientId: String, - val pid: String, - val fingerprint: String, + val uuid: RequestId = randomRequestId(), + val patientPseudonym: PatientPseudonym, + val pid: PatientId, + @Column("fingerprint") + val fingerprint: Fingerprint, val type: RequestType, var status: RequestStatus, var processedAt: Instant = Instant.now(), @Embedded.Nullable var report: Report? = null -) +) { + constructor( + uuid: RequestId, + patientPseudonym: PatientPseudonym, + pid: PatientId, + fingerprint: Fingerprint, + type: RequestType, + status: RequestStatus + ) : + this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now()) + + constructor( + uuid: RequestId, + patientPseudonym: PatientPseudonym, + pid: PatientId, + fingerprint: Fingerprint, + type: RequestType, + status: RequestStatus, + processedAt: Instant + ) : + this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt) +} +@JvmRecord data class Report( val description: String, val dataQualityReport: String = "" ) +@JvmRecord data class CountedState( val count: Int, val status: RequestStatus, @@ -57,17 +81,17 @@ data class CountedState( interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> { - fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request> + fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request> - fun findByUuidEquals(uuid: String): Optional<Request> + fun findByUuidEquals(uuid: RequestId): Optional<Request> - fun findRequestByPatientId(patientId: String, pageable: Pageable): Page<Request> + fun findRequestByPatientPseudonym(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> @Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;") fun countStates(): List<CountedState> @Query("SELECT count(*) AS count, status FROM (" + - "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " + + "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + "WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " + ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") fun findPatientUniqueStates(): List<CountedState> @@ -76,7 +100,7 @@ interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRep fun countDeleteStates(): List<CountedState> @Query("SELECT count(*) AS count, status FROM (" + - "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " + + "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + "WHERE type = 'DELETE'" + ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") fun findPatientUniqueDeleteStates(): List<CountedState> diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt index fc5d617..4838689 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt @@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.monitoring.RequestStatus import org.slf4j.LoggerFactory @@ -62,7 +63,7 @@ class KafkaMtbFileSender( val dummyMtbFile = MtbFile.builder() .withConsent( Consent.builder() - .withPatient(request.patientId) + .withPatient(request.patientId.value) .withStatus(Consent.Status.REJECTED) .build() ) @@ -98,8 +99,8 @@ class KafkaMtbFileSender( } private fun key(request: MtbFileSender.DeleteRequest): String { - return "{\"pid\": \"${request.patientId}\"}" + return "{\"pid\": \"${request.patientId.value}\"}" } - data class Data(val requestId: String, val content: MtbFile) + data class Data(val requestId: RequestId, val content: MtbFile) }
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt index aca972b..8d994c5 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt @@ -20,6 +20,8 @@ package dev.dnpm.etl.processor.output import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.RequestStatus import org.springframework.http.HttpStatusCode @@ -32,9 +34,9 @@ interface MtbFileSender { data class Response(val status: RequestStatus, val body: String = "") - data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile) + data class MtbFileRequest(val requestId: RequestId, val mtbFile: MtbFile) - data class DeleteRequest(val requestId: String, val patientId: String) + data class DeleteRequest(val requestId: RequestId, val patientId: PatientPseudonym) } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt index e1aecb7..58459b9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt @@ -40,8 +40,7 @@ class RestMtbFileSender( override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { try { return retryTemplate.execute<MtbFileSender.Response, Exception> { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + val headers = getHttpHeaders() val entityReq = HttpEntity(request.mtbFile, headers) val response = restTemplate.postForEntity( "${restTargetProperties.uri}/MTBFile", @@ -70,8 +69,7 @@ class RestMtbFileSender( override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { try { return retryTemplate.execute<MtbFileSender.Response, Exception> { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + val headers = getHttpHeaders() val entityReq = HttpEntity(null, headers) restTemplate.delete( "${restTargetProperties.uri}/Patient/${request.patientId}", @@ -94,4 +92,18 @@ class RestMtbFileSender( return this.restTargetProperties.uri.orEmpty() } + private fun getHttpHeaders(): HttpHeaders { + val username = restTargetProperties.username + val password = restTargetProperties.password + val headers = HttpHeaders() + headers.setContentType(MediaType.APPLICATION_JSON) + + if (username.isNullOrBlank() || password.isNullOrBlank()) { + return headers + } + + headers.setBasicAuth(username, password) + return headers + } + }
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt index d18cd2c..e80f6ec 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt @@ -19,6 +19,8 @@ package dev.dnpm.etl.processor.pseudonym +import dev.dnpm.etl.processor.PatientId +import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties class PseudonymizeService( @@ -26,10 +28,10 @@ class PseudonymizeService( private val configProperties: PseudonymizeConfigProperties ) { - fun patientPseudonym(patientId: String): String { + fun patientPseudonym(patientId: PatientId): PatientPseudonym { return when (generator) { - is GpasPseudonymGenerator -> generator.generate(patientId) - else -> "${configProperties.prefix}_${generator.generate(patientId)}" + is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value)) + else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}") } } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt index ef25787..bf645f6 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt @@ -20,6 +20,7 @@ package dev.dnpm.etl.processor.pseudonym import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.PatientId import org.apache.commons.codec.digest.DigestUtils /** Replaces patient ID with generated patient pseudonym @@ -29,7 +30,7 @@ import org.apache.commons.codec.digest.DigestUtils * @return The MTB file containing patient pseudonymes */ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { - val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id) + val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value this.episode?.patient = patientPseudonym this.carePlans?.forEach { it.patient = patientPseudonym } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt b/src/main/kotlin/dev/dnpm/etl/processor/security/TokenService.kt index f084408..44b04e8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/security/TokenService.kt @@ -17,7 +17,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package dev.dnpm.etl.processor.services +package dev.dnpm.etl.processor.security import jakarta.annotation.PostConstruct import org.springframework.data.annotation.Id diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRoleService.kt index 6649f7d..174f8a9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRoleService.kt @@ -17,11 +17,8 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package dev.dnpm.etl.processor.services +package dev.dnpm.etl.processor.security -import dev.dnpm.etl.processor.security.Role -import dev.dnpm.etl.processor.security.UserRole -import dev.dnpm.etl.processor.security.UserRoleRepository import org.springframework.data.repository.findByIdOrNull import org.springframework.security.core.session.SessionRegistry import org.springframework.security.oauth2.core.oidc.user.OidcUser diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt index bdf07cb..f4e6222 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.services import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.MtbFile +import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Request @@ -49,25 +50,27 @@ class RequestProcessor( ) { fun processMtbFile(mtbFile: MtbFile) { - processMtbFile(mtbFile, UUID.randomUUID().toString()) + processMtbFile(mtbFile, randomRequestId()) } - fun processMtbFile(mtbFile: MtbFile, requestId: String) { - val pid = mtbFile.patient.id + fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) { + val pid = PatientId(mtbFile.patient.id) mtbFile pseudonymizeWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile)) + val patientPseudonym = PatientPseudonym(request.mtbFile.patient.id) + requestService.save( Request( - uuid = requestId, - patientId = request.mtbFile.patient.id, - pid = pid, - fingerprint = fingerprint(request.mtbFile), - status = RequestStatus.UNKNOWN, - type = RequestType.MTB_FILE + requestId, + patientPseudonym, + pid, + fingerprint(request.mtbFile), + RequestType.MTB_FILE, + RequestStatus.UNKNOWN ) ) @@ -98,31 +101,33 @@ class RequestProcessor( } private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean { + val patientPseudonym = PatientPseudonym(pseudonymizedMtbFile.patient.id) + val lastMtbFileRequestForPatient = - requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id) - val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id) + requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym) + val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym) return null != lastMtbFileRequestForPatient && !isLastRequestDeletion && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile) } - fun processDeletion(patientId: String) { - processDeletion(patientId, UUID.randomUUID().toString()) + fun processDeletion(patientId: PatientId) { + processDeletion(patientId, randomRequestId()) } - fun processDeletion(patientId: String, requestId: String) { + fun processDeletion(patientId: PatientId, requestId: RequestId) { try { val patientPseudonym = pseudonymizeService.patientPseudonym(patientId) requestService.save( Request( - uuid = requestId, - patientId = patientPseudonym, - pid = patientId, - fingerprint = fingerprint(patientPseudonym), - status = RequestStatus.UNKNOWN, - type = RequestType.DELETE + requestId, + patientPseudonym, + patientId, + fingerprint(patientPseudonym.value), + RequestType.DELETE, + RequestStatus.UNKNOWN ) ) @@ -144,9 +149,9 @@ class RequestProcessor( requestService.save( Request( uuid = requestId, - patientId = "???", + patientPseudonym = emptyPatientPseudonym(), pid = patientId, - fingerprint = "", + fingerprint = Fingerprint.empty(), status = RequestStatus.ERROR, type = RequestType.DELETE, report = Report("Fehler bei der Pseudonymisierung") @@ -155,14 +160,16 @@ class RequestProcessor( } } - private fun fingerprint(mtbFile: MtbFile): String { + private fun fingerprint(mtbFile: MtbFile): Fingerprint { return fingerprint(objectMapper.writeValueAsString(mtbFile)) } - private fun fingerprint(s: String): String { - return Base32().encodeAsString(DigestUtils.sha256(s)) - .replace("=", "") - .lowercase() + private fun fingerprint(s: String): Fingerprint { + return Fingerprint( + Base32().encodeAsString(DigestUtils.sha256(s)) + .replace("=", "") + .lowercase() + ) } }
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt index e0043d2..757b353 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt @@ -19,11 +19,13 @@ package dev.dnpm.etl.processor.services -import dev.dnpm.etl.processor.monitoring.Request -import dev.dnpm.etl.processor.monitoring.RequestRepository -import dev.dnpm.etl.processor.monitoring.RequestStatus -import dev.dnpm.etl.processor.monitoring.RequestType +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.monitoring.* +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service +import java.util.* @Service class RequestService( @@ -32,15 +34,32 @@ class RequestService( fun save(request: Request) = requestRepository.save(request) - fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository - .findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym) + fun findAll(): Iterable<Request> = requestRepository.findAll() - fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) = + fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable) + + fun findByUuid(uuid: RequestId): Optional<Request> = + requestRepository.findByUuidEquals(uuid) + + fun findRequestByPatientId(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> = requestRepository.findRequestByPatientPseudonym(patientPseudonym, pageable) + + fun allRequestsByPatientPseudonym(patientPseudonym: PatientPseudonym) = requestRepository + .findAllByPatientPseudonymOrderByProcessedAtDesc(patientPseudonym) + + fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: PatientPseudonym) = Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym)) - fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) = + fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) = Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym)) + fun countStates(): Iterable<CountedState> = requestRepository.countStates() + + fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates() + + fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates() + + fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates() + companion object { fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt index 4048348..ecb2ec7 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt @@ -19,8 +19,8 @@ package dev.dnpm.etl.processor.services +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.Report -import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus import org.slf4j.LoggerFactory import org.springframework.context.event.EventListener @@ -31,7 +31,7 @@ import java.util.* @Service class ResponseProcessor( - private val requestRepository: RequestRepository, + private val requestService: RequestService, private val statisticsUpdateProducer: Sinks.Many<Any> ) { @@ -39,7 +39,7 @@ class ResponseProcessor( @EventListener(classes = [ResponseEvent::class]) fun handleResponseEvent(event: ResponseEvent) { - requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({ + requestService.findByUuid(event.requestUuid).ifPresentOrElse({ it.processedAt = event.timestamp it.status = event.status @@ -76,7 +76,7 @@ class ResponseProcessor( } } - requestRepository.save(it) + requestService.save(it) statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST) }, { @@ -87,7 +87,7 @@ class ResponseProcessor( } data class ResponseEvent( - val requestUuid: String, + val requestUuid: RequestId, val timestamp: Instant, val status: RequestStatus, val body: Optional<String> = Optional.empty() diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt index a29010f..12e824d 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt @@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.services.kafka import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.ObjectMapper +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.output.asRequestStatus import dev.dnpm.etl.processor.services.ResponseEvent @@ -47,7 +48,7 @@ class KafkaResponseProcessor( Optional.empty() }.ifPresentOrElse({ responseBody -> val event = ResponseEvent( - responseBody.requestId, + RequestId(responseBody.requestId), Instant.ofEpochMilli(data.timestamp()), responseBody.statusCode.asRequestStatus(), when (responseBody.statusCode.asRequestStatus()) { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/types.kt b/src/main/kotlin/dev/dnpm/etl/processor/types.kt new file mode 100644 index 0000000..b2f13ef --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/types.kt @@ -0,0 +1,49 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor + +import java.util.* + +class Fingerprint(val value: String) { + override fun hashCode() = value.hashCode() + + override fun equals(other: Any?) = other is Fingerprint && other.value == value + + companion object { + fun empty() = Fingerprint("") + } +} + +@JvmInline +value class RequestId(val value: String) { + + fun isBlank() = value.isBlank() + +} + +fun randomRequestId() = RequestId(UUID.randomUUID().toString()) + +@JvmInline +value class PatientId(val value: String) + +@JvmInline +value class PatientPseudonym(val value: String) + +fun emptyPatientPseudonym() = PatientPseudonym("")
\ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt index eb9d541..25ec7cc 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt @@ -27,10 +27,10 @@ import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.security.UserRole -import dev.dnpm.etl.processor.services.Token -import dev.dnpm.etl.processor.services.TokenService +import dev.dnpm.etl.processor.security.Token +import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.TransformationService -import dev.dnpm.etl.processor.services.UserRoleService +import dev.dnpm.etl.processor.security.UserRoleService import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.MediaType import org.springframework.http.codec.ServerSentEvent @@ -56,7 +56,7 @@ class ConfigController( @GetMapping fun index(model: Model): String { val outputConnectionAvailable = - connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable() + connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable() val gPasConnectionAvailable = connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable() @@ -127,10 +127,11 @@ class ConfigController( } else { model.addAttribute("tokensEnabled", true) val result = tokenService.addToken(name) - if (result.isSuccess) { - model.addAttribute("newTokenValue", result.getOrDefault("")) + result.onSuccess { + model.addAttribute("newTokenValue", it) model.addAttribute("success", true) - } else { + } + result.onFailure { model.addAttribute("success", false) } model.addAttribute("tokens", tokenService.findAll()) @@ -182,6 +183,7 @@ class ConfigController( } @GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) + @ResponseBody fun events(): Flux<ServerSentEvent<Any>> { return connectionCheckUpdateProducer.asFlux().map { val event = when (it) { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt index 6a256aa..54920b1 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt @@ -20,9 +20,10 @@ package dev.dnpm.etl.processor.web import dev.dnpm.etl.processor.NotFoundException +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.ReportService -import dev.dnpm.etl.processor.monitoring.RequestId -import dev.dnpm.etl.processor.monitoring.RequestRepository +import dev.dnpm.etl.processor.services.RequestService import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort import org.springframework.data.web.PageableDefault @@ -35,7 +36,7 @@ import org.springframework.web.bind.annotation.RequestMapping @Controller @RequestMapping(path = ["/"]) class HomeController( - private val requestRepository: RequestRepository, + private val requestService: RequestService, private val reportService: ReportService ) { @@ -44,20 +45,20 @@ class HomeController( @PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable, model: Model ): String { - val requests = requestRepository.findAll(pageable) + val requests = requestService.findAll(pageable) model.addAttribute("requests", requests) return "index" } - @GetMapping(path = ["patient/{patientId}"]) + @GetMapping(path = ["patient/{patientPseudonym}"]) fun byPatient( - @PathVariable patientId: String, + @PathVariable patientPseudonym: PatientPseudonym, @PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable, model: Model ): String { - val requests = requestRepository.findRequestByPatientId(patientId, pageable) - model.addAttribute("patientId", patientId) + val requests = requestService.findRequestByPatientId(patientPseudonym, pageable) + model.addAttribute("patientPseudonym", patientPseudonym.value) model.addAttribute("requests", requests) return "index" @@ -65,7 +66,7 @@ class HomeController( @GetMapping(path = ["/report/{id}"]) fun report(@PathVariable id: RequestId, model: Model): String { - val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException() + val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException() model.addAttribute("request", request) model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport)) diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt index daa6af3..c034cb4 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt @@ -19,9 +19,9 @@ package dev.dnpm.etl.processor.web -import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType +import dev.dnpm.etl.processor.services.RequestService import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.MediaType import org.springframework.http.codec.ServerSentEvent @@ -41,15 +41,15 @@ import java.time.temporal.ChronoUnit class StatisticsRestController( @Qualifier("statisticsUpdateProducer") private val statisticsUpdateProducer: Sinks.Many<Any>, - private val requestRepository: RequestRepository + private val requestService: RequestService ) { @GetMapping(path = ["requeststates"]) fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { val states = if (delete) { - requestRepository.countDeleteStates() + requestService.countDeleteStates() } else { - requestRepository.countStates() + requestService.countStates() } return states @@ -79,7 +79,7 @@ class StatisticsRestController( } val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin")) - val data = requestRepository.findAll() + val data = requestService.findAll() .filter { it.type == requestType } .filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) } .groupBy { formatter.format(it.processedAt) } @@ -115,9 +115,9 @@ class StatisticsRestController( @GetMapping(path = ["requestpatientstates"]) fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { val states = if (delete) { - requestRepository.findPatientUniqueDeleteStates() + requestService.findPatientUniqueDeleteStates() } else { - requestRepository.findPatientUniqueStates() + requestService.findPatientUniqueStates() } return states.map { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3d4827c..895f026 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,17 +3,34 @@ spring: compose: file: ./dev-compose.yml + security: + oauth2: + client: + registration: + custom: + client-name: App-Dev + client-id: app-dev + client-secret: very-secret-ae3f7a-5a9f-1190 + scope: + - openid + provider: + custom: + issuer-uri: https://dnpm.dev/auth/realms/intern + user-name-attribute: name + app: - #rest: - # uri: http://localhost:9000/bwhc/etl/api - kafka: - topic: test - response-topic: test_response - servers: localhost:9094 - #security: - # admin-user: admin - # admin-password: "{noop}very-secret" + rest: + uri: http://localhost:9000/bwhc/etl/api + #kafka: + # topic: test + # response-topic: test_response + # servers: localhost:9094 + security: + admin-user: admin + admin-password: "{noop}very-secret" + enable-oidc: "true" server: port: 8000 + diff --git a/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql b/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql new file mode 100644 index 0000000..bb2b0cc --- /dev/null +++ b/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql @@ -0,0 +1 @@ +ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
\ No newline at end of file diff --git a/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql b/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql new file mode 100644 index 0000000..bb2b0cc --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql @@ -0,0 +1 @@ +ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
\ No newline at end of file diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 1dd68ed..7066e2b 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -22,6 +22,10 @@ --bg-gray-op: rgba(112, 128, 144, .35); } +* { + font-family: sans-serif; +} + html { background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em); min-height: 100vh; @@ -30,7 +34,6 @@ html { body { margin: 0 0 5em 0; - font-family: sans-serif; font-size: .8rem; color: var(--text); @@ -619,6 +622,10 @@ input.inline:focus-visible { text-align: center; } +.notification.info { + color: var(--bg-blue); +} + .notification.success { color: var(--bg-green); } @@ -651,6 +658,7 @@ input.inline:focus-visible { border-radius: 0 .5em .5em .5em; display: none; padding: 1em; + background: white; } .tabcontent.active { diff --git a/src/main/resources/templates/configs/gPasConnectionAvailable.html b/src/main/resources/templates/configs/gPasConnectionAvailable.html index 6dccc60..a9a8517 100644 --- a/src/main/resources/templates/configs/gPasConnectionAvailable.html +++ b/src/main/resources/templates/configs/gPasConnectionAvailable.html @@ -2,15 +2,20 @@ <h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2> </th:block> <th:block th:if="${gPasConnectionAvailable != null}"> - <h2><span th:if="${gPasConnectionAvailable}">✅</span><span th:if="${not(gPasConnectionAvailable)}">⚡</span> Verbindung zu gPAS</h2> + <h2><span th:if="${gPasConnectionAvailable.available}">✅</span><span th:if="${not(gPasConnectionAvailable.available)}">⚡</span> Verbindung zu gPAS</h2> <div> - Die Verbindung ist aktuell - <strong th:if="${gPasConnectionAvailable}" style="color: green">verfügbar.</strong> - <strong th:if="${not(gPasConnectionAvailable)}" style="color: red">nicht verfügbar.</strong> + Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}"></time> + | + Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}"></time> + </div> + <div> + <span>Die Verbindung ist aktuell</span> + <strong th:if="${gPasConnectionAvailable.available}" style="color: green">verfügbar.</strong> + <strong th:if="${not(gPasConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong> </div> <div class="connection-display border"> <img th:src="@{/server.png}" alt="ETL-Processor" /> - <span class="connection" th:classappend="${gPasConnectionAvailable ? 'available' : ''}"></span> + <span class="connection" th:classappend="${gPasConnectionAvailable.available ? 'available' : ''}"></span> <img th:src="@{/server.png}" alt="gPAS" /> <span>ETL-Processor</span> <span></span> diff --git a/src/main/resources/templates/configs/outputConnectionAvailable.html b/src/main/resources/templates/configs/outputConnectionAvailable.html index 2b18b75..4b7f8d1 100644 --- a/src/main/resources/templates/configs/outputConnectionAvailable.html +++ b/src/main/resources/templates/configs/outputConnectionAvailable.html @@ -1,16 +1,26 @@ -<h2><span th:if="${outputConnectionAvailable}">✅</span><span th:if="${not(outputConnectionAvailable)}">⚡</span> MTB-File Verbindung</h2> -<div> - Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell - <strong th:if="${outputConnectionAvailable}" style="color: green">verfügbar.</strong> - <strong th:if="${not(outputConnectionAvailable)}" style="color: red">nicht verfügbar.</strong> -</div> -<div class="connection-display border"> - <img th:src="@{/server.png}" alt="ETL-Processor" /> - <span class="connection" th:classappend="${outputConnectionAvailable ? 'available' : ''}"></span> - <img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" /> - <img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" /> - <span>ETL-Processor</span> - <span></span> - <span th:if="${mtbFileSender.startsWith('Rest')}">bwHC-Backend</span> - <span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span> -</div>
\ No newline at end of file +<th:block th:if="${outputConnectionAvailable == null}"> + <h2><span>🟦</span> Keine Ausgabenkonfiguration</h2> +</th:block> +<th:block th:if="${outputConnectionAvailable != null}"> + <h2><span th:if="${outputConnectionAvailable.available}">✅</span><span th:if="${not(outputConnectionAvailable.available)}">⚡</span> MTB-File Verbindung</h2> + <div> + Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(outputConnectionAvailable.timestamp)}"></time> + | + Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(outputConnectionAvailable.lastChange)}"></time> + </div> + <div> + Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell + <strong th:if="${outputConnectionAvailable.available}" style="color: green">verfügbar.</strong> + <strong th:if="${not(outputConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong> + </div> + <div class="connection-display border"> + <img th:src="@{/server.png}" alt="ETL-Processor" /> + <span class="connection" th:classappend="${outputConnectionAvailable.available ? 'available' : ''}"></span> + <img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" /> + <img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" /> + <span>ETL-Processor</span> + <span></span> + <span th:if="${mtbFileSender.startsWith('Rest')}">bwHC-Backend</span> + <span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span> + </div> +</th:block>
\ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index be3123b..7ca0b67 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -12,26 +12,30 @@ <h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}">⟳</a></h1> <div> - <h2 th:if="${patientId != null}"> - Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientId}">***</span> - <a class="btn btn-blue" th:if="${patientId != null}" th:href="@{/}">Alle anzeigen</a> + <h2 th:if="${patientPseudonym != null}"> + Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span> + <a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a> </h2> </div> - <div class="border"> - <div th:if="${patientId == null}" class="page-control"> + <div class="border" th:if="${requests.totalElements == 0}"> + <div class="notification info">Noch keine Anfragen eingegangen</div> + </div> + + <div class="border" th:if="${requests.totalElements > 0}"> + <div th:if="${patientPseudonym == null}" class="page-control"> <a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> <a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> <a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a> <a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a> </div> - <div th:if="${patientId != null}" class="page-control"> - <a id="first-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> - <a id="prev-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> + <div th:if="${patientPseudonym != null}" class="page-control"> + <a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a> + <a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> - <a id="next-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a> - <a id="last-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a> + <a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a> + <a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a> </div> <table class="paged"> <thead> @@ -57,11 +61,11 @@ <th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block> </td> <td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> - <td class="patient-id" th:if="${patientId != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> - [[ ${request.patientId} ]] + <td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> + [[ ${request.patientPseudonym} ]] </td> - <td class="patient-id" th:if="${patientId == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> - <a th:href="@{/patient/{pid}(pid=${request.patientId})}">[[ ${request.patientId} ]]</a> + <td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> + <a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a> </td> <td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td> </tr> diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html index 07f987c..21d1b48 100644 --- a/src/main/resources/templates/report.html +++ b/src/main/resources/templates/report.html @@ -31,7 +31,7 @@ <td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td> <td>[[ ${request.uuid} ]]</td> <td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> - <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientId} ]]</td> + <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientPseudonym} ]]</td> <td class="patient-id" sec:authorize="not authenticated">***</td> </tr> </tbody> diff --git a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt new file mode 100644 index 0000000..55d6327 --- /dev/null +++ b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt @@ -0,0 +1,29 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor + +import org.mockito.ArgumentMatchers + +inline fun <reified T> anyValueClass(): T { + val unboxedClass = T::class.java.declaredFields.first().type + return ArgumentMatchers.any(unboxedClass as Class<T>) + ?: T::class.java.getDeclaredMethod("box-impl", unboxedClass) + .invoke(null, null) as T +}
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt index 1157644..7753dbc 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.header.internals.RecordHeader @@ -31,7 +32,6 @@ import org.apache.kafka.common.record.TimestampType import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -77,7 +77,7 @@ class KafkaInputListenerTest { kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile))) - verify(requestProcessor, times(1)).processDeletion(anyString()) + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) } @Test @@ -92,7 +92,7 @@ class KafkaInputListenerTest { ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty()) ) - verify(requestProcessor, times(1)).processMtbFile(any(), anyString()) + verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) } @Test @@ -106,7 +106,7 @@ class KafkaInputListenerTest { kafkaInputListener.onMessage( ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty()) ) - verify(requestProcessor, times(1)).processDeletion(anyString(), anyString()) + verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass()) } }
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index 0b076a1..f9fe3f3 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -21,8 +21,8 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.services.RequestProcessor -import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -31,7 +31,6 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete @@ -129,9 +128,7 @@ class MtbFileRestControllerTest { } } - val captor = argumentCaptor<String>() - verify(requestProcessor, times(1)).processDeletion(captor.capture()) - assertThat(captor.firstValue).isEqualTo("TEST_12345678") + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) } @Test @@ -142,9 +139,7 @@ class MtbFileRestControllerTest { } } - val captor = argumentCaptor<String>() - verify(requestProcessor, times(1)).processDeletion(captor.capture()) - assertThat(captor.firstValue).isEqualTo("TEST_12345678") + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) } }
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt index 411c51e..655e29e 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt @@ -21,6 +21,8 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.monitoring.RequestStatus import org.assertj.core.api.Assertions.assertThat @@ -72,7 +74,7 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE))) + val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) assertThat(response.status).isEqualTo(testData.requestStatus) } @@ -86,7 +88,7 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) + val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(testData.requestStatus) } @@ -96,14 +98,14 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE))) + kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) val captor = argumentCaptor<String>() verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture()) assertThat(captor.firstValue).isNotNull assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}") assertThat(captor.secondValue).isNotNull - assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.ACTIVE))) + assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE))) } @Test @@ -112,14 +114,14 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) + kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val captor = argumentCaptor<String>() verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture()) assertThat(captor.firstValue).isNotNull assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}") assertThat(captor.secondValue).isNotNull - assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.REJECTED))) + assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED))) } @ParameterizedTest @@ -136,7 +138,7 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE))) + kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) val expectedCount = when (testData.exception) { // OK - No Retry @@ -162,7 +164,7 @@ class KafkaMtbFileSenderTest { completedFuture(SendResult<String, String>(null, null)) }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) + kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val expectedCount = when (testData.exception) { // OK - No Retry @@ -175,6 +177,9 @@ class KafkaMtbFileSenderTest { } companion object { + val TEST_REQUEST_ID = RequestId("TestId") + val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") + fun mtbFile(consentStatus: Consent.Status): MtbFile { return if (consentStatus == Consent.Status.ACTIVE) { MtbFile.builder() @@ -210,7 +215,7 @@ class KafkaMtbFileSenderTest { }.build() } - fun kafkaRecordData(requestId: String, consentStatus: Consent.Status): KafkaMtbFileSender.Data { + fun kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): KafkaMtbFileSender.Data { return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus)) } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt index df19ddb..9b6332a 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt @@ -20,6 +20,8 @@ package dev.dnpm.etl.processor.output import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.config.RestTargetProperties import dev.dnpm.etl.processor.monitoring.RequestStatus import org.assertj.core.api.Assertions.assertThat @@ -46,7 +48,7 @@ class RestMtbFileSenderTest { @BeforeEach fun setup() { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -64,7 +66,7 @@ class RestMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) + val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -79,7 +81,7 @@ class RestMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile)) + val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -88,7 +90,7 @@ class RestMtbFileSenderTest { @MethodSource("mtbFileRequestWithResponseSource") fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -108,7 +110,7 @@ class RestMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile)) + val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -117,7 +119,7 @@ class RestMtbFileSenderTest { @MethodSource("deleteRequestWithResponseSource") fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { val restTemplate = RestTemplate() - val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile") + val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) @@ -137,7 +139,7 @@ class RestMtbFileSenderTest { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) } - val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID")) + val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -149,6 +151,9 @@ class RestMtbFileSenderTest { val response: MtbFileSender.Response ) + val TEST_REQUEST_ID = RequestId("TestId") + val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") + private val warningBody = """ { "patient_id": "PID", diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt index d8c7813..fbc26ae 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt @@ -21,11 +21,11 @@ package dev.dnpm.etl.processor.pseudonym import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.anyValueClass import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.doAnswer @@ -52,7 +52,7 @@ class ExtensionsTest { doAnswer { it.arguments[0] "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) val mtbFile = fakeMtbFile() @@ -67,7 +67,7 @@ class ExtensionsTest { doAnswer { it.arguments[0] "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { "TESTDOMAIN" @@ -95,7 +95,7 @@ class ExtensionsTest { doAnswer { it.arguments[0] "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { "TESTDOMAIN" @@ -139,7 +139,7 @@ class ExtensionsTest { doAnswer { it.arguments[0] "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { "TESTDOMAIN" diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/TokenServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/security/TokenServiceTest.kt index 1fdc3d9..b93e9f5 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/TokenServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/security/TokenServiceTest.kt @@ -17,13 +17,12 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package dev.dnpm.etl.processor.services +package dev.dnpm.etl.processor.security import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock @@ -96,11 +95,11 @@ class TokenServiceTest { val actual = this.tokenService.addToken("Test Token") - val captor = ArgumentCaptor.forClass(Token::class.java) + val captor = argumentCaptor<Token>() verify(tokenRepository, times(1)).save(captor.capture()) assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() }) - assertThat(captor.value).satisfies( + assertThat(captor.firstValue).satisfies( Consumer { assertThat(it.name).isEqualTo("Test Token") }, Consumer { assertThat(it.username).isEqualTo("testtoken") }, Consumer { assertThat(it.password).isEqualTo("{test}verysecret") } @@ -116,13 +115,13 @@ class TokenServiceTest { this.tokenService.deleteToken(42) - val stringCaptor = ArgumentCaptor.forClass(String::class.java) + val stringCaptor = argumentCaptor<String>() verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture()) - assertThat(stringCaptor.value).isEqualTo("testtoken") + assertThat(stringCaptor.firstValue).isEqualTo("testtoken") - val tokenCaptor = ArgumentCaptor.forClass(Token::class.java) + val tokenCaptor = argumentCaptor<Token>() verify(tokenRepository, times(1)).delete(tokenCaptor.capture()) - assertThat(tokenCaptor.value.id).isEqualTo(42) + assertThat(tokenCaptor.firstValue.id).isEqualTo(42) } @Test diff --git a/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt new file mode 100644 index 0000000..39ba7c0 --- /dev/null +++ b/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt @@ -0,0 +1,202 @@ +/* + * This file is part of ETL-Processor + * + * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package dev.dnpm.etl.processor.security + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.* +import org.springframework.security.core.session.SessionInformation +import org.springframework.security.core.session.SessionRegistry +import org.springframework.security.oauth2.core.oidc.OidcIdToken +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import java.time.Instant +import java.util.* + +@ExtendWith(MockitoExtension::class) +class UserRoleServiceTest { + + private lateinit var userRoleRepository: UserRoleRepository + private lateinit var sessionRegistry: SessionRegistry + + private lateinit var userRoleService: UserRoleService + + @BeforeEach + fun setup( + @Mock userRoleRepository: UserRoleRepository, + @Mock sessionRegistry: SessionRegistry + ) { + this.userRoleRepository = userRoleRepository + this.sessionRegistry = sessionRegistry + + this.userRoleService = UserRoleService(userRoleRepository, sessionRegistry) + } + + @Test + fun shouldDelegateFindAllToRepository() { + userRoleService.findAll() + + verify(userRoleRepository, times(1)).findAll() + } + + @Nested + inner class WithExistingUserRole { + + @BeforeEach + fun setup() { + doAnswer { invocation -> + Optional.of( + UserRole(invocation.getArgument(0), "patrick.tester", Role.USER) + ) + }.whenever(userRoleRepository).findById(any<Long>()) + + doAnswer { _ -> + listOf( + dummyPrincipal() + ) + }.whenever(sessionRegistry).allPrincipals + } + + @Test + fun shouldUpdateUserRole() { + userRoleService.updateUserRole(1, Role.ADMIN) + + val userRoleCaptor = argumentCaptor<UserRole>() + verify(userRoleRepository, times(1)).save(userRoleCaptor.capture()) + + assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L) + assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.ADMIN) + } + + @Test + fun shouldExpireSessionOnUpdate() { + val dummySessions = dummySessions() + whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn( + dummySessions + ) + + assertThat(dummySessions.filter { it.isExpired }).hasSize(0) + + userRoleService.updateUserRole(1, Role.ADMIN) + + verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>()) + + assertThat(dummySessions.filter { it.isExpired }).hasSize(2) + } + + @Test + fun shouldDeleteUserRole() { + userRoleService.deleteUserRole(1) + + val userRoleCaptor = argumentCaptor<UserRole>() + verify(userRoleRepository, times(1)).delete(userRoleCaptor.capture()) + + assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L) + assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.USER) + } + + @Test + fun shouldExpireSessionOnDelete() { + val dummySessions = dummySessions() + whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn( + dummySessions + ) + + assertThat(dummySessions.filter { it.isExpired }).hasSize(0) + + userRoleService.deleteUserRole(1) + + verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>()) + + assertThat(dummySessions.filter { it.isExpired }).hasSize(2) + } + } + + @Nested + inner class WithoutExistingUserRole { + + @BeforeEach + fun setup() { + doAnswer { _ -> + Optional.empty<UserRole>() + }.whenever(userRoleRepository).findById(any<Long>()) + } + + @Test + fun shouldNotUpdateUserRole() { + userRoleService.updateUserRole(1, Role.ADMIN) + + verify(userRoleRepository, never()).save(any<UserRole>()) + } + + @Test + fun shouldNotExpireSessionOnUpdate() { + userRoleService.updateUserRole(1, Role.ADMIN) + + verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>()) + } + + @Test + fun shouldNotDeleteUserRole() { + userRoleService.deleteUserRole(1) + + verify(userRoleRepository, never()).delete(any<UserRole>()) + } + + @Test + fun shouldNotExpireSessionOnDelete() { + userRoleService.deleteUserRole(1) + + verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>()) + } + + } + + + companion object { + private fun dummyPrincipal() = DefaultOidcUser( + listOf(), + OidcIdToken( + "anytokenvalue", + Instant.now(), + Instant.now().plusSeconds(10), + mapOf("sub" to "testsub", "preferred_username" to "patrick.tester") + ) + ) + + private fun dummySessions() = listOf( + SessionInformation( + dummyPrincipal(), + "SESSIONID1", + Date.from(Instant.now()), + ), + SessionInformation( + dummyPrincipal(), + "SESSIONID2", + Date.from(Instant.now()), + ) + ) + } +}
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt index 611c0ff..1c58d5d 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt @@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.services import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.RequestStatus @@ -32,7 +33,6 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.junit.jupiter.MockitoExtension @@ -41,7 +41,6 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.whenever import org.springframework.context.ApplicationEventPublisher import java.time.Instant -import java.util.* @ExtendWith(MockitoExtension::class) @@ -88,24 +87,24 @@ class RequestProcessorTest { fun testShouldSendMtbFileDuplicationAndSaveUnknownRequestStatusAtFirst() { doAnswer { Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) - }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString()) + }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) doAnswer { false - }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString()) + }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) doAnswer { it.arguments[0] as String - }.`when`(pseudonymizeService).patientPseudonym(any()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { it.arguments[0] @@ -147,24 +146,24 @@ class RequestProcessorTest { fun testShouldDetectMtbFileDuplicationAndSendDuplicationEvent() { doAnswer { Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) - }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString()) + }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) doAnswer { false - }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString()) + }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) doAnswer { it.arguments[0] as String - }.`when`(pseudonymizeService).patientPseudonym(any()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { it.arguments[0] @@ -206,28 +205,28 @@ class RequestProcessorTest { fun testShouldSendMtbFileAndSendSuccessEvent() { doAnswer { Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "different", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("different"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) - }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString()) + }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) doAnswer { false - }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString()) + }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>()) + }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>()) doAnswer { it.arguments[0] as String - }.`when`(pseudonymizeService).patientPseudonym(any()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { it.arguments[0] @@ -269,28 +268,28 @@ class RequestProcessorTest { fun testShouldSendMtbFileAndSendErrorEvent() { doAnswer { Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "different", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("different"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) - }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString()) + }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) doAnswer { false - }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString()) + }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>()) + }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>()) doAnswer { it.arguments[0] as String - }.`when`(pseudonymizeService).patientPseudonym(any()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { it.arguments[0] @@ -332,13 +331,13 @@ class RequestProcessorTest { fun testShouldSendDeleteRequestAndSaveUnknownRequestStatusAtFirst() { doAnswer { "PSEUDONYM" - }.`when`(pseudonymizeService).patientPseudonym(anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { MtbFileSender.Response(status = RequestStatus.UNKNOWN) - }.`when`(sender).send(any<MtbFileSender.DeleteRequest>()) + }.whenever(sender).send(any<MtbFileSender.DeleteRequest>()) - this.requestProcessor.processDeletion("TEST_12345678901") + this.requestProcessor.processDeletion(TEST_PATIENT_ID) val requestCaptor = argumentCaptor<Request>() verify(requestService, times(1)).save(requestCaptor.capture()) @@ -350,13 +349,13 @@ class RequestProcessorTest { fun testShouldSendDeleteRequestAndSendSuccessEvent() { doAnswer { "PSEUDONYM" - }.`when`(pseudonymizeService).patientPseudonym(anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.`when`(sender).send(any<MtbFileSender.DeleteRequest>()) + }.whenever(sender).send(any<MtbFileSender.DeleteRequest>()) - this.requestProcessor.processDeletion("TEST_12345678901") + this.requestProcessor.processDeletion(TEST_PATIENT_ID) val eventCaptor = argumentCaptor<ResponseEvent>() verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) @@ -368,13 +367,13 @@ class RequestProcessorTest { fun testShouldSendDeleteRequestAndSendErrorEvent() { doAnswer { "PSEUDONYM" - }.`when`(pseudonymizeService).patientPseudonym(anyString()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.`when`(sender).send(any<MtbFileSender.DeleteRequest>()) + }.whenever(sender).send(any<MtbFileSender.DeleteRequest>()) - this.requestProcessor.processDeletion("TEST_12345678901") + this.requestProcessor.processDeletion(TEST_PATIENT_ID) val eventCaptor = argumentCaptor<ResponseEvent>() verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) @@ -384,9 +383,9 @@ class RequestProcessorTest { @Test fun testShouldSendDeleteRequestWithPseudonymErrorAndSaveErrorRequestStatus() { - doThrow(RuntimeException()).`when`(pseudonymizeService).patientPseudonym(anyString()) + doThrow(RuntimeException()).whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - this.requestProcessor.processDeletion("TEST_12345678901") + this.requestProcessor.processDeletion(TEST_PATIENT_ID) val requestCaptor = argumentCaptor<Request>() verify(requestService, times(1)).save(requestCaptor.capture()) @@ -400,7 +399,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] as String - }.`when`(pseudonymizeService).patientPseudonym(any()) + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doAnswer { it.arguments[0] @@ -408,7 +407,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>()) + }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>()) val mtbFile = MtbFile.builder() .withPatient( @@ -442,4 +441,8 @@ class RequestProcessorTest { assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS) } + companion object { + val TEST_PATIENT_ID = PatientId("TEST_12345678901") + } + }
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt index 3cf8804..2e289c5 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt @@ -19,6 +19,7 @@ package dev.dnpm.etl.processor.services +import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus @@ -30,8 +31,8 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.whenever import java.time.Instant -import java.util.* @ExtendWith(MockitoExtension::class) class RequestServiceTest { @@ -41,14 +42,14 @@ class RequestServiceTest { private lateinit var requestService: RequestService private fun anyRequest() = any(Request::class.java) ?: Request( - id = 0L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_dummy", - pid = "PX", - fingerprint = "dummy", - type = RequestType.MTB_FILE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-08-08T02:00:00Z") + 0L, + randomRequestId(), + PatientPseudonym("TEST_dummy"), + PatientId("PX"), + Fingerprint("dummy"), + RequestType.MTB_FILE, + RequestStatus.SUCCESS, + Instant.parse("2023-08-08T02:00:00Z") ) @BeforeEach @@ -63,34 +64,34 @@ class RequestServiceTest { fun shouldIndicateLastRequestIsDeleteRequest() { val requests = listOf( Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-07-07T00:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-07-07T00:00:00Z") ), Request( - id = 2L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdefd", - type = RequestType.DELETE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-07-07T02:00:00Z") + 2L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdefd"), + RequestType.DELETE, + RequestStatus.WARNING, + Instant.parse("2023-07-07T02:00:00Z") ), Request( - id = 3L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.UNKNOWN, - processedAt = Instant.parse("2023-08-11T00:00:00Z") + 3L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.UNKNOWN, + Instant.parse("2023-08-11T00:00:00Z") ) ) @@ -103,34 +104,34 @@ class RequestServiceTest { fun shouldIndicateLastRequestIsNotDeleteRequest() { val requests = listOf( Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-07-07T00:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-07-07T00:00:00Z") ), Request( - id = 2L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-07-07T02:00:00Z") + 2L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-07-07T02:00:00Z") ), Request( - id = 3L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.MTB_FILE, - status = RequestStatus.UNKNOWN, - processedAt = Instant.parse("2023-08-11T00:00:00Z") + 3L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.MTB_FILE, + RequestStatus.UNKNOWN, + Instant.parse("2023-08-11T00:00:00Z") ) ) @@ -143,31 +144,31 @@ class RequestServiceTest { fun shouldReturnPatientsLastRequest() { val requests = listOf( Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.DELETE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-07-07T02:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.DELETE, + RequestStatus.SUCCESS, + Instant.parse("2023-07-07T02:00:00Z") ), Request( - id = 1L, - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678902", - pid = "P2", - fingerprint = "0123456789abcdef2", - type = RequestType.MTB_FILE, - status = RequestStatus.WARNING, - processedAt = Instant.parse("2023-08-08T00:00:00Z") + 1L, + randomRequestId(), + PatientPseudonym("TEST_12345678902"), + PatientId("P2"), + Fingerprint("0123456789abcdef2"), + RequestType.MTB_FILE, + RequestStatus.WARNING, + Instant.parse("2023-08-08T00:00:00Z") ) ) val actual = RequestService.lastMtbFileRequestForPatientPseudonym(requests) assertThat(actual).isInstanceOf(Request::class.java) - assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef2") + assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef2")) } @Test @@ -184,16 +185,16 @@ class RequestServiceTest { doAnswer { val obj = it.arguments[0] as Request obj.copy(id = 1L) - }.`when`(requestRepository).save(anyRequest()) + }.whenever(requestRepository).save(anyRequest()) val request = Request( - uuid = UUID.randomUUID().toString(), - patientId = "TEST_12345678901", - pid = "P1", - fingerprint = "0123456789abcdef1", - type = RequestType.DELETE, - status = RequestStatus.SUCCESS, - processedAt = Instant.parse("2023-07-07T02:00:00Z") + randomRequestId(), + PatientPseudonym("TEST_12345678901"), + PatientId("P1"), + Fingerprint("0123456789abcdef1"), + RequestType.DELETE, + RequestStatus.SUCCESS, + Instant.parse("2023-07-07T02:00:00Z") ) requestService.save(request) @@ -203,23 +204,23 @@ class RequestServiceTest { @Test fun allRequestsByPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() { - requestService.allRequestsByPatientPseudonym("TEST_12345678901") + requestService.allRequestsByPatientPseudonym(PatientPseudonym("TEST_12345678901")) - verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString()) + verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) } @Test fun lastMtbFileRequestForPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() { - requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901") + requestService.lastMtbFileRequestForPatientPseudonym(PatientPseudonym("TEST_12345678901")) - verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString()) + verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) } @Test fun isLastRequestDeletionShouldRequestAllRequestsForPatientPseudonym() { - requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901") + requestService.isLastRequestWithKnownStatusDeletion(PatientPseudonym("TEST_12345678901")) - verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString()) + verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) } }
\ No newline at end of file diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt index b9e4b7f..465d8b8 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt @@ -19,8 +19,8 @@ package dev.dnpm.etl.processor.services +import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.monitoring.Request -import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType import org.assertj.core.api.Assertions.assertThat @@ -29,7 +29,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource -import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.* @@ -40,64 +39,64 @@ import java.util.* @ExtendWith(MockitoExtension::class) class ResponseProcessorTest { - private lateinit var requestRepository: RequestRepository + private lateinit var requestService: RequestService private lateinit var statisticsUpdateProducer: Sinks.Many<Any> private lateinit var responseProcessor: ResponseProcessor private val testRequest = Request( 1L, - "TestID1234", - "PSEUDONYM-A", - "1", - "dummyfingerprint", + RequestId("TestID1234"), + PatientPseudonym("PSEUDONYM-A"), + PatientId("1"), + Fingerprint("dummyfingerprint"), RequestType.MTB_FILE, RequestStatus.UNKNOWN ) @BeforeEach fun setup( - @Mock requestRepository: RequestRepository, + @Mock requestService: RequestService, @Mock statisticsUpdateProducer: Sinks.Many<Any> ) { - this.requestRepository = requestRepository + this.requestService = requestService this.statisticsUpdateProducer = statisticsUpdateProducer - this.responseProcessor = ResponseProcessor(requestRepository, statisticsUpdateProducer) + this.responseProcessor = ResponseProcessor(requestService, statisticsUpdateProducer) } @Test fun shouldNotSaveStatusForUnknownRequest() { doAnswer { Optional.empty<Request>() - }.whenever(requestRepository).findByUuidEquals(anyString()) + }.whenever(requestService).findByUuid(anyValueClass()) val event = ResponseEvent( - "TestID1234", + RequestId("TestID1234"), Instant.parse("2023-09-09T00:00:00Z"), RequestStatus.SUCCESS ) this.responseProcessor.handleResponseEvent(event) - verify(requestRepository, never()).save(any()) + verify(requestService, never()).save(any()) } @Test fun shouldNotSaveStatusWithUnknownState() { doAnswer { Optional.of(testRequest) - }.whenever(requestRepository).findByUuidEquals(anyString()) + }.whenever(requestService).findByUuid(anyValueClass()) val event = ResponseEvent( - "TestID1234", + RequestId("TestID1234"), Instant.parse("2023-09-09T00:00:00Z"), RequestStatus.UNKNOWN ) this.responseProcessor.handleResponseEvent(event) - verify(requestRepository, never()).save(any()) + verify(requestService, never()).save(any<Request>()) } @ParameterizedTest @@ -105,10 +104,10 @@ class ResponseProcessorTest { fun shouldSaveStatusForKnownRequest(requestStatus: RequestStatus) { doAnswer { Optional.of(testRequest) - }.whenever(requestRepository).findByUuidEquals(anyString()) + }.whenever(requestService).findByUuid(anyValueClass()) val event = ResponseEvent( - "TestID1234", + RequestId("TestID1234"), Instant.parse("2023-09-09T00:00:00Z"), requestStatus ) @@ -116,7 +115,7 @@ class ResponseProcessorTest { this.responseProcessor.handleResponseEvent(event) val captor = argumentCaptor<Request>() - verify(requestRepository, times(1)).save(captor.capture()) + verify(requestService, times(1)).save(captor.capture()) assertThat(captor.firstValue).isNotNull assertThat(captor.firstValue.status).isEqualTo(requestStatus) } |
