From 542dc61811026c6f1d50306ec4b81022a6d37b70 Mon Sep 17 00:00:00 2001 From: Jakub Lidke Date: Mon, 5 May 2025 12:28:54 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20*=20Status=20pr=C3=BCfung=20gIcs=20?= =?UTF-8?q?=C3=BCberarbeitet.*=20Tests=20f=C3=BCr=20GicsConsentService=20i?= =?UTF-8?q?mplementiert.=20*=20=C3=9Cberschreiben=20des=20MTB-File=20Conse?= =?UTF-8?q?nt,=20falls=20gICS=20aktiviert=20ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev-compose.yml | 37 ------ .../processor/consent/GicsConsentService.java | 64 ++++++---- .../processor/config/AppConfigProperties.kt | 3 +- .../processor/input/MtbFileRestController.kt | 34 +++--- .../consent/GicsConsentServiceTest.java | 92 ++++++++++++++- .../input/MtbFileRestControllerTest.kt | 111 ++++++++++++++++-- 6 files changed, 256 insertions(+), 85 deletions(-) diff --git a/dev-compose.yml b/dev-compose.yml index ef76c49..faaedc5 100644 --- a/dev-compose.yml +++ b/dev-compose.yml @@ -59,40 +59,3 @@ services: # POSTGRES_DB: dev # POSTGRES_USER: dev # POSTGRES_PASSWORD: dev - - - mysql: - image: mysql:8 - container_name: gics-mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: root - TZ: Europe/Berlin - ports: - - "3306:3306" - volumes: - - ./sqls:/docker-entrypoint-initdb.d - command: --max_allowed_packet=20M --default-time-zone=Europe/Berlin - - gics: - image: registry.diz.uni-marburg.de/ths/gics:2023.1.3 - container_name: gics-wildfly - restart: unless-stopped - ports: - - "8090:8080" - - "127.0.0.1:9992:9990" - extra_hosts: - - "host.docker.internal:host-gateway" - depends_on: - - mysql - - consent-data-loader: - image: confluentinc/cp-kafkacat:7.1.12 - entrypoint: ["/bin/bash", "-c"] - command: > - "kafkacat -b kafka:19092 -K: -t consent-json -P -l /data/consent-data.ndjson" - volumes: - - ./consent-data.ndjson:/data/consent-data.ndjson:ro - depends_on: - kafka: - condition: service_healthy \ No newline at end of file diff --git a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java index 9780be8..53dfee6 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java @@ -1,12 +1,14 @@ package dev.dnpm.etl.processor.consent; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.DataFormatException; import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GIcsConfigProperties; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.r4.model.BooleanType; import org.hl7.fhir.r4.model.Coding; import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.OperationOutcome; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.StringType; @@ -31,7 +33,7 @@ public class GicsConsentService implements ICheckConsent { private final GIcsConfigProperties gIcsConfigProperties; - public final String IS_CONSENTED_PATH = "/ttp-fhir/fhir/gics/$isConsented"; + public static final String IS_CONSENTED_ENDPOINT = "/$isConsented"; private final RetryTemplate retryTemplate; private final RestTemplate restTemplate; private final FhirContext fhirContext; @@ -56,8 +58,7 @@ public class GicsConsentService implements ICheckConsent { throw new IllegalArgumentException( "gICS base URL is empty - should call gICS with false configuration."); } - url = UriComponentsBuilder.fromHttpUrl(gIcsBaseUri) - .path(IS_CONSENTED_PATH) + url = UriComponentsBuilder.fromHttpUrl(gIcsBaseUri).path(IS_CONSENTED_ENDPOINT) .toUriString(); } return url; @@ -84,14 +85,20 @@ public class GicsConsentService implements ICheckConsent { .setSystem(configProperties.getPersonIdentifierSystem()))); result.addParameter(new ParametersParameterComponent().setName("domain") .setValue(new StringType().setValue(configProperties.getConsentDomainName()))); - result.addParameter(new ParametersParameterComponent().setName("policy") - .setValue(new Coding().setCode(configProperties.getPolicyCode()) + result.addParameter(new ParametersParameterComponent().setName("policy").setValue( + new Coding().setCode(configProperties.getPolicyCode()) .setSystem(configProperties.getPolicySystem()))); + + /* + * is mandatory parameter, but we ignore it via additional configuration parameter + * 'ignoreVersionNumber'. + */ result.addParameter(new ParametersParameterComponent().setName("version") - .setValue(new StringType().setValue(configProperties.getParameterVersion()))); + .setValue(new StringType().setValue("1.1"))); /* add config parameter with: - * ignoreVersionNumber -> true + * ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient + * has possibly signed or not, therefore we are happy with any version found. * unknownStateIsConsideredAsDecline -> true */ var config = new ParametersParameterComponent().setName("config").addPart( @@ -110,9 +117,10 @@ public class GicsConsentService implements ICheckConsent { HttpEntity requestEntity = new HttpEntity<>(parameterAsXml, this.httpHeader); ResponseEntity responseEntity; try { + var url = getGicsUri(); + responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(getGicsUri(), HttpMethod.POST, requestEntity, - String.class)); + ctx -> restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class)); } catch (RestClientException e) { var msg = String.format("Get consents status request failed reason: '%s", e.getMessage()); @@ -123,8 +131,7 @@ public class GicsConsentService implements ICheckConsent { var msg = String.format( "Get consents status process has been terminated. termination reason: '%s", terminatedRetryException.getMessage()); - log.error(msg - ); + log.error(msg); return null; } @@ -153,18 +160,31 @@ public class GicsConsentService implements ICheckConsent { if (consentStatusResponse == null) { return TtpConsentStatus.FAILED_TO_ASK; } - var responseParameters = fhirContext.newJsonParser() - .parseResource(Parameters.class, consentStatusResponse); + try { + var response = fhirContext.newJsonParser().parseResource(consentStatusResponse); - var responseValue = responseParameters.getParameter("consented").getValue(); - var isConsented = responseValue.castToBoolean(responseValue); - if (!isConsented.hasValue()) { - return TtpConsentStatus.FAILED_TO_ASK; - } - if (isConsented.booleanValue()) { - return TtpConsentStatus.CONSENTED; - } else { - return TtpConsentStatus.CONSENT_MISSING_OR_REJECTED; + if (response instanceof Parameters responseParameters) { + + var responseValue = responseParameters.getParameter("consented").getValue(); + var isConsented = responseValue.castToBoolean(responseValue); + if (!isConsented.hasValue()) { + return TtpConsentStatus.FAILED_TO_ASK; + } + if (isConsented.booleanValue()) { + return TtpConsentStatus.CONSENTED; + } else { + return TtpConsentStatus.CONSENT_MISSING_OR_REJECTED; + } + } else if (response instanceof OperationOutcome outcome) { + + log.error( + "failed to get consent status from ttp. probably configuration error. outcome: ", + fhirContext.newJsonParser().encodeToString(outcome)); + + } + } catch (DataFormatException dfe) { + log.error("failed to parse response to FHIR R4 resource.", dfe); } + return TtpConsentStatus.FAILED_TO_ASK; } } 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 6f127c6..6b6fd82 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -92,8 +92,7 @@ data class GIcsConfigProperties( /** * Consent Policy which should be used for consent check */ - val policySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3", - val parameterVersion: String = "1.1" + val policySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3" ) { companion object { const val NAME = "app.consent.gics" 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 4f45e83..8a418fc 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -45,20 +45,11 @@ class MtbFileRestController( return ResponseEntity.ok("Test") } - @PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) + @PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE]) fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { - var ttpConsentStatus = constService.isConsented(mtbFile.patient.id) - - // received status REJECTED overrides TTP value. Also in case of disabled consent service, - // we need to override IGNORED status - if (mtbFile.consent.status == Consent.Status.REJECTED) ttpConsentStatus = - TtpConsentStatus.CONSENT_MISSING_OR_REJECTED - - val isConsentOK = mtbFile.consent.status == Consent.Status.ACTIVE && (ttpConsentStatus.equals( - TtpConsentStatus.CONSENTED - ) || ttpConsentStatus.equals( - TtpConsentStatus.IGNORED - )) + val consentStatusBooleanPair = checkConsentStatus(mtbFile) + var ttpConsentStatus = consentStatusBooleanPair.first + val isConsentOK = consentStatusBooleanPair.second if (isConsentOK) { logger.debug("Accepted MTB File (bwHC V1) for processing") requestProcessor.processMtbFile(mtbFile) @@ -71,7 +62,22 @@ class MtbFileRestController( return ResponseEntity.accepted().build() } - @PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] ) + private fun checkConsentStatus(mtbFile: MtbFile): Pair { + var ttpConsentStatus = constService.isConsented(mtbFile.patient.id) + + val isConsentOK = + (ttpConsentStatus.equals(TtpConsentStatus.IGNORED) && mtbFile.consent.status == Consent.Status.ACTIVE) || + ttpConsentStatus.equals( + TtpConsentStatus.CONSENTED + ) + if (ttpConsentStatus.equals(TtpConsentStatus.IGNORED) && mtbFile.consent.status == Consent.Status.REJECTED) { + // in case ttp check is disabled - we propagate rejected status anyway + ttpConsentStatus = TtpConsentStatus.CONSENT_MISSING_OR_REJECTED + } + return Pair(ttpConsentStatus, isConsentOK) + } + + @PostMapping(consumes = [CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE]) fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity { logger.debug("Accepted MTB File (DNPM V2) for processing") requestProcessor.processMtbFile(mtbFile) diff --git a/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java b/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java index 600c6eb..5a633f6 100644 --- a/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java +++ b/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java @@ -1,14 +1,102 @@ package dev.dnpm.etl.processor.consent; import static org.assertj.core.api.Assertions.assertThat; - +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.dnpm.etl.processor.config.AppConfiguration; +import dev.dnpm.etl.processor.config.AppFhirConfig; +import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity; +import org.hl7.fhir.r4.model.OperationOutcome.IssueType; +import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.client.MockRestServiceServer; -//@ExtendWith(MockitoExtension.class) + + +@ContextConfiguration(classes = {GicsConsentService.class, + AppConfiguration.class, ObjectMapper.class}) +@TestPropertySource(properties = {"app.consent.gics.enabled=true", + "app.consent.gics.gIcsBaseUri=http://localhost:8090/ttp-fhir/fhir/gics"}) +@RestClientTest public class GicsConsentServiceTest { + public static final String GICS_BASE_URI = "http://localhost:8090/ttp-fhir/fhir/gics"; + @Autowired + MockRestServiceServer mockRestServiceServer; + @Autowired + GicsConsentService gicsConsentService; + @Autowired + AppConfiguration appConfiguration; + + @Autowired + AppFhirConfig appFhirConfig; + + @BeforeEach + public void setUp() { + mockRestServiceServer = MockRestServiceServer.createServer(appConfiguration.restTemplate()); + } + @Test void isConsented() { + final Parameters responseConsented = new Parameters().addParameter( + new ParametersParameterComponent().setName("consented") + .setValue(new BooleanType().setValue(true))); + + mockRestServiceServer.expect( + requestTo("http://localhost:8090/ttp-fhir/fhir/gics" + GicsConsentService.IS_CONSENTED_ENDPOINT)).andRespond( + withSuccess(appFhirConfig.fhirContext().newJsonParser() + .encodeResourceToString(responseConsented), + MediaType.APPLICATION_JSON)); + + var consentStatus = gicsConsentService.isConsented("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.CONSENTED); } + + @Test + void consentRevoced() { + final Parameters responseRevoced = new Parameters().addParameter( + new ParametersParameterComponent().setName("consented") + .setValue(new BooleanType().setValue(false))); + + mockRestServiceServer.expect( + requestTo("http://localhost:8090/ttp-fhir/fhir/gics" + GicsConsentService.IS_CONSENTED_ENDPOINT)).andRespond( + withSuccess(appFhirConfig.fhirContext().newJsonParser() + .encodeResourceToString(responseRevoced), + MediaType.APPLICATION_JSON)); + + var consentStatus = gicsConsentService.isConsented("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED); + } + + + @Test + void gicsParameterInvalid() { + final OperationOutcome responseErrorOutcome = new OperationOutcome().addIssue( + new OperationOutcomeIssueComponent().setSeverity( + IssueSeverity.ERROR).setCode(IssueType.PROCESSING) + .setDiagnostics("Invalid policy parameter...")); + + mockRestServiceServer.expect( + requestTo(GICS_BASE_URI + GicsConsentService.IS_CONSENTED_ENDPOINT)).andRespond( + withSuccess(appFhirConfig.fhirContext().newJsonParser() + .encodeResourceToString(responseErrorOutcome), + MediaType.APPLICATION_JSON)); + + var consentStatus = gicsConsentService.isConsented("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK); + } + + } 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 af23a16..655b426 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -21,22 +21,29 @@ package dev.dnpm.etl.processor.input import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* -import dev.dnpm.etl.processor.consent.ConsentCheckedIgnored -import dev.dnpm.etl.processor.consent.TtpConsentStatus +import de.ukw.ccc.bwhc.dto.Consent.Status import dev.dnpm.etl.processor.CustomMediaType +import dev.dnpm.etl.processor.consent.ConsentCheckedIgnored +import dev.dnpm.etl.processor.consent.GicsConsentService +import dev.dnpm.etl.processor.consent.TtpConsentStatus import dev.dnpm.etl.processor.services.RequestProcessor import dev.pcvolkmer.mv64e.mtb.Mtb 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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mock -import org.mockito.Mockito.* +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.anyValueClass +import org.mockito.kotlin.whenever import org.springframework.core.io.ClassPathResource import org.springframework.http.MediaType +import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.post @@ -54,6 +61,7 @@ class MtbFileRestControllerTest { private lateinit var requestProcessor: RequestProcessor + @BeforeEach fun setup( @Mock requestProcessor: RequestProcessor @@ -66,7 +74,7 @@ class MtbFileRestControllerTest { @Test fun shouldProcessPostRequest() { mockMvc.post("/mtbfile") { - content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Status.ACTIVE)) contentType = MediaType.APPLICATION_JSON }.andExpect { status { @@ -81,7 +89,7 @@ class MtbFileRestControllerTest { fun shouldProcessPostRequestWithRejectedConsent() { mockMvc.post("/mtbfile") { content = - objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + objectMapper.writeValueAsString(bwhcMtbFileContent(Status.REJECTED)) contentType = MediaType.APPLICATION_JSON }.andExpect { status { @@ -110,6 +118,93 @@ class MtbFileRestControllerTest { } } + @TestPropertySource( + properties = ["app.consent.gics.enabled=true", + "app.consent.gics.gIcsBaseUri=http://localhost:8090/ttp-fhir/fhir/gics"] + ) + @Nested + inner class BwhcRequestsCheckConsentViaTtp { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + private lateinit var gicsConsentService: GicsConsentService + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor, + @Mock gicsConsentService: GicsConsentService + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor, gicsConsentService) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + this.gicsConsentService = gicsConsentService + + } + + @ParameterizedTest + @ValueSource(strings = ["ACTIVE", "REJECTED"]) + fun shouldProcessPostRequest(status: String) { + + whenever(gicsConsentService.isConsented(any())).thenReturn(TtpConsentStatus.CONSENTED) + + mockMvc.post("/mtbfile") { + content = + objectMapper.writeValueAsString(bwhcMtbFileContent(Status.valueOf(status))) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + + @ParameterizedTest + @ValueSource(strings = ["ACTIVE", "REJECTED"]) + fun shouldProcessPostRequestWithRejectedConsent(status: String) { + + whenever(gicsConsentService.isConsented(any())).thenReturn(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED) + + mockMvc.post("/mtbfile") { + content = + objectMapper.writeValueAsString(bwhcMtbFileContent(Status.valueOf(status))) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + // consent status from ttp should override file consent value + verify(requestProcessor, times(1)).processDeletion( + anyValueClass(), + org.mockito.kotlin.eq(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED) + ) + } + + @Test + fun shouldProcessDeleteRequest() { + + mockMvc.delete("/mtbfile/TEST_12345678").andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion( + anyValueClass(), + org.mockito.kotlin.eq(TtpConsentStatus.IGNORED) + ) + verify(gicsConsentService, times(0)).isConsented(any()) + + } + } + + @Nested inner class BwhcRequestsWithAlias { @@ -129,7 +224,7 @@ class MtbFileRestControllerTest { @Test fun shouldProcessPostRequest() { mockMvc.post("/mtb") { - content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Status.ACTIVE)) contentType = MediaType.APPLICATION_JSON }.andExpect { status { @@ -144,7 +239,7 @@ class MtbFileRestControllerTest { fun shouldProcessPostRequestWithRejectedConsent() { mockMvc.post("/mtb") { content = - objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + objectMapper.writeValueAsString(bwhcMtbFileContent(Status.REJECTED)) contentType = MediaType.APPLICATION_JSON }.andExpect { status { @@ -212,7 +307,7 @@ class MtbFileRestControllerTest { } companion object { - fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder() + fun bwhcMtbFileContent(consentStatus: Status) = MtbFile.builder() .withPatient( Patient.builder() .withId("TEST_12345678")