1
0
mirror of https://github.com/pcvolkmer/mv64e-etl-processor synced 2025-09-13 09:02:50 +00:00

feat: check consent for DNPM 2.1 requests (#126)

Co-authored-by: Jakub Lidke <jakub.lidke@uni-marburg.de>
This commit is contained in:
2025-08-15 12:37:42 +02:00
committed by GitHub
parent be513f305a
commit 3eb1c79cec
21 changed files with 934 additions and 286 deletions

View File

@@ -102,6 +102,18 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
.id("TEST_12345678")
.build()
)
.metadata(
MvhMetadata
.builder()
.modelProjectConsent(
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.PERMIT).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
).build()
)
.build()
)
.diagnoses(
listOf(
MtbDiagnosis.builder()

View File

@@ -20,8 +20,9 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.GicsConsentService
import dev.dnpm.etl.processor.consent.MtbFileConsentService
import dev.dnpm.etl.processor.input.KafkaInputListener
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
@@ -53,7 +54,8 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean
AppSecurityConfiguration::class,
KafkaAutoConfiguration::class,
AppKafkaConfiguration::class,
AppRestConfiguration::class
AppRestConfiguration::class,
ConsentEvaluator::class
]
)
@MockitoBean(types = [ObjectMapper::class])
@@ -281,7 +283,8 @@ class AppConfigurationTest {
@Nested
@TestPropertySource(
properties = [
"app.consent.service=GICS"
"app.consent.service=GICS",
"app.consent.gics.uri=http://localhost:9000",
]
)
inner class AppConfigurationConsentGicsTest(private val context: ApplicationContext) {
@@ -293,27 +296,12 @@ class AppConfigurationTest {
}
@Nested
@TestPropertySource(
properties = [
"app.consent.gics.enabled=true"
]
)
inner class AppConfigurationConsentGicsEnabledTest(private val context: ApplicationContext) {
@Test
fun shouldUseConfiguredGenerator() {
assertThat(context.getBean(GicsConsentService::class.java)).isNotNull
}
}
@Nested
inner class AppConfigurationConsentBuildinTest(private val context: ApplicationContext) {
@Test
fun shouldUseConfiguredGenerator() {
assertThat(context.getBean(ConsentByMtbFile::class.java)).isNotNull
assertThat(context.getBean(MtbFileConsentService::class.java)).isNotNull
}
}

View File

@@ -20,11 +20,11 @@
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.anyValueClass
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.ConsentEvaluation
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.MtbFileConsentService
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.consent.IGetConsent
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.services.RequestProcessor
@@ -57,32 +57,37 @@ import java.util.*
classes = [
MtbFileRestController::class,
AppSecurityConfiguration::class,
ConsentByMtbFile::class, IGetConsent::class
MtbFileConsentService::class
]
)
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class])
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class, ConsentEvaluator::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.consent.gics.enabled=false"
"app.security.enable-tokens=true"
]
)
class MtbFileRestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
lateinit var mockMvc: MockMvc
lateinit var requestProcessor: RequestProcessor
lateinit var consentEvaluator: ConsentEvaluator
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
@Autowired requestProcessor: RequestProcessor
@Autowired requestProcessor: RequestProcessor,
@Autowired consentEvaluator: ConsentEvaluator
) {
this.mockMvc = mockMvc
this.requestProcessor = requestProcessor
this.consentEvaluator = consentEvaluator
doAnswer {
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_GIVEN, true)
}.whenever(consentEvaluator).check(any())
}
@Test
@@ -167,8 +172,7 @@ class MtbFileRestControllerTest {
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true",
"app.security.enable-oidc=true",
"app.consent.gics.enabled=false"
"app.security.enable-oidc=true"
]
)
inner class WithOidcEnabled {

View File

@@ -4,10 +4,10 @@ public enum ConsentDomain {
/**
* MII Broad consent
*/
BroadConsent,
BROAD_CONSENT,
/**
* GenomDe Modelvohaben §64e
* GenomDe Modellvorhaben §64e
*/
Modelvorhaben64e
MODELLVORHABEN_64E
}

View File

@@ -4,17 +4,9 @@ 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 java.util.Date;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.r4.model.BooleanType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Coding;
import org.hl7.fhir.r4.model.DateType;
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.*;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
import org.hl7.fhir.r4.model.StringType;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,109 +14,149 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.TerminatedRetryException;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.Date;
public class GicsConsentService implements IGetConsent {
/**
* Service to request Consent from remote gICS installation
*
* @since 0.11
*/
public class GicsConsentService implements IConsentService {
private final Logger log = LoggerFactory.getLogger(GicsConsentService.class);
public static final String IS_CONSENTED_ENDPOINT = "/$isConsented";
public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = "/$currentPolicyStatesForPerson";
private final RetryTemplate retryTemplate;
private final RestTemplate restTemplate;
private final FhirContext fhirContext;
private final HttpHeaders httpHeader;
private final GIcsConfigProperties gIcsConfigProperties;
private String url;
public GicsConsentService(GIcsConfigProperties gIcsConfigProperties,
RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
public GicsConsentService(
GIcsConfigProperties gIcsConfigProperties,
RetryTemplate retryTemplate,
RestTemplate restTemplate,
AppFhirConfig appFhirConfig
) {
this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate;
this.fhirContext = appFhirConfig.fhirContext();
httpHeader = buildHeader(gIcsConfigProperties.getUsername(),
gIcsConfigProperties.getPassword());
this.gIcsConfigProperties = gIcsConfigProperties;
log.info("GicsConsentService initialized...");
}
public String getGicsUri(String endpoint) {
if (url == null) {
final String gIcsBaseUri = gIcsConfigProperties.getUri();
if (StringUtils.isBlank(gIcsBaseUri)) {
throw new IllegalArgumentException(
"gICS base URL is empty - should call gICS with false configuration.");
}
url = UriComponentsBuilder.fromUriString(gIcsBaseUri).path(endpoint)
.toUriString();
}
return url;
}
@NotNull
private static HttpHeaders buildHeader(String gPasUserName, String gPasPassword) {
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
if (StringUtils.isBlank(gPasUserName) || StringUtils.isBlank(gPasPassword)) {
return headers;
}
headers.setBasicAuth(gPasUserName, gPasPassword);
return headers;
}
protected static Parameters getIsConsentedRequestParam(GIcsConfigProperties configProperties,
String personIdentifierValue) {
protected Parameters getFhirRequestParameters(
String personIdentifierValue
) {
var result = new Parameters();
result.addParameter(new ParametersParameterComponent().setName("personIdentifier").setValue(
new Identifier().setValue(personIdentifierValue)
.setSystem(configProperties.getPersonIdentifierSystem())));
result.addParameter(new ParametersParameterComponent().setName("domain")
.setValue(new StringType().setValue(configProperties.getBroadConsentDomainName())));
result.addParameter(new ParametersParameterComponent().setName("policy").setValue(
new Coding().setCode(configProperties.getBroadConsentPolicyCode())
.setSystem(configProperties.getBroadConsentPolicySystem())));
result.addParameter(
new ParametersParameterComponent()
.setName("personIdentifier")
.setValue(
new Identifier()
.setValue(personIdentifierValue)
.setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem())
)
);
result.addParameter(
new ParametersParameterComponent()
.setName("domain")
.setValue(
new StringType()
.setValue(this.gIcsConfigProperties.getBroadConsentDomainName())
)
);
result.addParameter(
new ParametersParameterComponent()
.setName("policy")
.setValue(
new Coding()
.setCode(this.gIcsConfigProperties.getBroadConsentPolicyCode())
.setSystem(this.gIcsConfigProperties.getBroadConsentPolicySystem())
)
);
/*
* is mandatory parameter, but we ignore it via additional configuration parameter
* 'ignoreVersionNumber'.
*/
result.addParameter(new ParametersParameterComponent().setName("version")
.setValue(new StringType().setValue("1.1")));
result.addParameter(
new ParametersParameterComponent()
.setName("version")
.setValue(new StringType().setValue("1.1")
)
);
/* add config parameter with:
* 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(
new ParametersParameterComponent().setName("ignoreVersionNumber")
.setValue(new BooleanType().setValue(true))).addPart(
new ParametersParameterComponent().setName("unknownStateIsConsideredAsDecline")
.setValue(new BooleanType().setValue(false)));
var config = new ParametersParameterComponent()
.setName("config")
.addPart(
new ParametersParameterComponent()
.setName("ignoreVersionNumber")
.setValue(new BooleanType().setValue(true))
)
.addPart(
new ParametersParameterComponent()
.setName("unknownStateIsConsideredAsDecline")
.setValue(new BooleanType().setValue(false))
);
result.addParameter(config);
return result;
}
private URI endpointUri(String endpoint) {
assert this.gIcsConfigProperties.getUri() != null;
return UriComponentsBuilder.fromUriString(this.gIcsConfigProperties.getUri()).path(endpoint).build().toUri();
}
private HttpHeaders headersWithHttpBasicAuth() {
assert this.gIcsConfigProperties.getUri() != null;
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_XML);
if (
StringUtils.isBlank(this.gIcsConfigProperties.getUsername())
|| StringUtils.isBlank(this.gIcsConfigProperties.getPassword())
) {
return headers;
}
headers.setBasicAuth(this.gIcsConfigProperties.getUsername(), this.gIcsConfigProperties.getPassword());
return headers;
}
protected String callGicsApi(Parameters parameter, String endpoint) {
var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter);
HttpEntity<String> requestEntity = new HttpEntity<>(parameterAsXml, this.httpHeader);
ResponseEntity<String> responseEntity;
HttpEntity<String> requestEntity = new HttpEntity<>(parameterAsXml, this.headersWithHttpBasicAuth());
try {
var url = getGicsUri(endpoint);
var responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(endpointUri(endpoint), HttpMethod.POST, requestEntity, String.class)
);
responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) {
return responseEntity.getBody();
} else {
var msg = String.format(
"Trusted party system reached but request failed! code: '%s' response: '%s'",
responseEntity.getStatusCode(), responseEntity.getBody());
log.error(msg);
return null;
}
} catch (RestClientException e) {
var msg = String.format("Get consents status request failed reason: '%s",
e.getMessage());
@@ -137,36 +169,29 @@ public class GicsConsentService implements IGetConsent {
terminatedRetryException.getMessage());
log.error(msg);
return null;
}
if (responseEntity.getStatusCode().is2xxSuccessful()) {
return responseEntity.getBody();
} else {
var msg = String.format(
"Trusted party system reached but request failed! code: '%s' response: '%s'",
responseEntity.getStatusCode(), responseEntity.getBody());
log.error(msg);
return null;
}
}
@Override
public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) {
var parameter = GicsConsentService.getIsConsentedRequestParam(gIcsConfigProperties,
personIdentifierValue);
var consentStatusResponse = callGicsApi(parameter,
GicsConsentService.IS_CONSENTED_ENDPOINT);
var consentStatusResponse = callGicsApi(
getFhirRequestParameters(personIdentifierValue),
GicsConsentService.IS_CONSENTED_ENDPOINT
);
return evaluateConsentResponse(consentStatusResponse);
}
protected Bundle currentConsentForPersonAndTemplate(String personIdentifierValue,
ConsentDomain targetConsentDomain, Date requestDate) {
protected Bundle currentConsentForPersonAndTemplate(
String personIdentifierValue,
ConsentDomain consentDomain,
Date requestDate
) {
String consentDomain = getConsentDomain(targetConsentDomain);
var requestParameter = GicsConsentService.buildRequestParameterCurrentPolicyStatesForPerson(
gIcsConfigProperties, personIdentifierValue, requestDate, consentDomain);
var requestParameter = buildRequestParameterCurrentPolicyStatesForPerson(
personIdentifierValue,
requestDate,
consentDomain
);
var consentDataSerialized = callGicsApi(requestParameter,
GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT);
@@ -184,8 +209,8 @@ public class GicsConsentService implements IGetConsent {
"Consent request failed! Check outcome:\n " + consentDataSerialized;
log.error(errorMessage);
throw new IllegalStateException(errorMessage);
} else if (iBaseResource instanceof Bundle) {
return (Bundle) iBaseResource;
} else if (iBaseResource instanceof Bundle bundle) {
return bundle;
} else {
String errorMessage = "Consent request failed! Unexpected response received! -> "
+ consentDataSerialized;
@@ -195,40 +220,52 @@ public class GicsConsentService implements IGetConsent {
}
@NotNull
private String getConsentDomain(ConsentDomain targetConsentDomain) {
String consentDomain;
switch (targetConsentDomain) {
case BroadConsent -> consentDomain = gIcsConfigProperties.getBroadConsentDomainName();
case Modelvorhaben64e ->
consentDomain = gIcsConfigProperties.getGenomDeConsentDomainName();
default -> throw new IllegalArgumentException(
"target ConsentDomain is missing but must be provided!");
}
return consentDomain;
private String getConsentDomainName(ConsentDomain targetConsentDomain) {
return switch (targetConsentDomain) {
case BROAD_CONSENT -> gIcsConfigProperties.getBroadConsentDomainName();
case MODELLVORHABEN_64E -> gIcsConfigProperties.getGenomDeConsentDomainName();
};
}
protected static Parameters buildRequestParameterCurrentPolicyStatesForPerson(
GIcsConfigProperties gIcsConfigProperties, String personIdentifierValue, Date requestDate,
String targetDomain) {
protected Parameters buildRequestParameterCurrentPolicyStatesForPerson(
String personIdentifierValue,
Date requestDate,
ConsentDomain consentDomain
) {
var requestParameter = new Parameters();
requestParameter.addParameter(new ParametersParameterComponent().setName("personIdentifier")
.setValue(new Identifier().setValue(personIdentifierValue)
.setSystem(gIcsConfigProperties.getPersonIdentifierSystem())));
requestParameter.addParameter(
new ParametersParameterComponent()
.setName("personIdentifier")
.setValue(
new Identifier()
.setValue(personIdentifierValue)
.setSystem(this.gIcsConfigProperties.getPersonIdentifierSystem())
)
);
requestParameter.addParameter(new ParametersParameterComponent().setName("domain")
.setValue(new StringType().setValue(targetDomain)));
requestParameter.addParameter(
new ParametersParameterComponent()
.setName("domain")
.setValue(new StringType().setValue(getConsentDomainName(consentDomain)))
);
Parameters nestedConfigParameters = new Parameters();
nestedConfigParameters.addParameter(
new ParametersParameterComponent().setName("idMatchingType").setValue(
new Coding().setSystem(
"https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType")
.setCode("AT_LEAST_ONE"))).addParameter("ignoreVersionNumber", false)
nestedConfigParameters
.addParameter(
new ParametersParameterComponent()
.setName("idMatchingType")
.setValue(new Coding()
.setSystem("https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType")
.setCode("AT_LEAST_ONE")
)
)
.addParameter("ignoreVersionNumber", false)
.addParameter("unknownStateIsConsideredAsDecline", false)
.addParameter("requestDate", new DateType().setValue(requestDate));
requestParameter.addParameter(new ParametersParameterComponent().setName("config").addPart()
.setResource(nestedConfigParameters));
requestParameter.addParameter(
new ParametersParameterComponent().setName("config").addPart().setResource(nestedConfigParameters)
);
return requestParameter;
}
@@ -265,17 +302,6 @@ public class GicsConsentService implements IGetConsent {
@Override
public Bundle getConsent(String patientId, Date requestDate, ConsentDomain consentDomain) {
switch (consentDomain) {
case BroadConsent -> {
return currentConsentForPersonAndTemplate(patientId, ConsentDomain.BroadConsent,
requestDate);
}
case Modelvorhaben64e -> {
return currentConsentForPersonAndTemplate(patientId,
ConsentDomain.Modelvorhaben64e, requestDate);
}
}
return new Bundle();
return currentConsentForPersonAndTemplate(patientId, consentDomain, requestDate);
}
}

View File

@@ -3,7 +3,7 @@ package dev.dnpm.etl.processor.consent;
import java.util.Date;
import org.hl7.fhir.r4.model.Bundle;
public interface IGetConsent {
public interface IConsentService {
/**
* Get broad consent status for a patient identifier

View File

@@ -5,11 +5,11 @@ import org.hl7.fhir.r4.model.Bundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ConsentByMtbFile implements IGetConsent {
public class MtbFileConsentService implements IConsentService {
private static final Logger log = LoggerFactory.getLogger(ConsentByMtbFile.class);
private static final Logger log = LoggerFactory.getLogger(MtbFileConsentService.class);
public ConsentByMtbFile() {
public MtbFileConsentService() {
log.info("ConsentCheckFileBased initialized...");
}

View File

@@ -73,8 +73,8 @@ data class GIcsConfigProperties(
*
*/
val uri: String?,
val username: String?,
val password: String?,
val username: String? = null,
val password: String? = null,
/**
* gICS specific system

View File

@@ -20,9 +20,9 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.MtbFileConsentService
import dev.dnpm.etl.processor.consent.GicsConsentService
import dev.dnpm.etl.processor.consent.IGetConsent
import dev.dnpm.etl.processor.consent.IConsentService
import dev.dnpm.etl.processor.monitoring.*
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator
@@ -218,7 +218,7 @@ class AppConfiguration {
retryTemplate: RetryTemplate,
restTemplate: RestTemplate,
appFhirConfig: AppFhirConfig
): IGetConsent {
): IConsentService {
return GicsConsentService(
gIcsConfigProperties,
retryTemplate,
@@ -234,7 +234,7 @@ class AppConfiguration {
gIcsConfigProperties: GIcsConfigProperties,
getObjectMapper: ObjectMapper,
appFhirConfig: AppFhirConfig,
gicsConsentService: IGetConsent
gicsConsentService: IConsentService
): ConsentProcessor {
return ConsentProcessor(
configProperties,
@@ -261,8 +261,8 @@ class AppConfiguration {
@Bean
@ConditionalOnMissingBean
fun iGetConsentService(): IGetConsent {
return ConsentByMtbFile()
fun iGetConsentService(): IConsentService {
return MtbFileConsentService()
}
}
@@ -271,13 +271,9 @@ class GicsEnabledCondition :
AnyNestedCondition(ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN) {
@ConditionalOnProperty(name = ["app.consent.service"], havingValue = "gics")
@ConditionalOnProperty(name = ["app.consent.gics.uri"])
class OnGicsServiceSelected {
// Just for Condition
}
@ConditionalOnProperty(name = ["app.consent.gics.enabled"], havingValue = "true")
class OnGicsEnabled {
// Just for Condition
}
}

View File

@@ -20,6 +20,7 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.input.KafkaInputListener
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
@@ -100,9 +101,10 @@ class AppKafkaConfiguration {
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
fun kafkaInputListener(
requestProcessor: RequestProcessor,
objectMapper: ObjectMapper
objectMapper: ObjectMapper,
consentEvaluator: ConsentEvaluator
): KafkaInputListener {
return KafkaInputListener(requestProcessor, objectMapper)
return KafkaInputListener(requestProcessor, consentEvaluator, objectMapper)
}
@Bean

View File

@@ -0,0 +1,66 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 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.consent
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.springframework.stereotype.Service
/**
* Evaluates consent using provided consent service and file based consent information
*/
@Service
class ConsentEvaluator(
private val consentService: IConsentService
) {
fun check(mtbFile: Mtb): ConsentEvaluation {
val ttpConsentStatus = consentService.getTtpBroadConsentStatus(mtbFile.patient.id)
val consentGiven = ttpConsentStatus == TtpConsentStatus.BROAD_CONSENT_GIVEN
|| ttpConsentStatus == TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT
// Aktuell nur Modellvorhaben Consent im File
|| ttpConsentStatus == TtpConsentStatus.UNKNOWN_CHECK_FILE && mtbFile.metadata?.modelProjectConsent?.provisions?.any {
it.purpose == ModelProjectConsentPurpose.SEQUENCING
&& it.type == ConsentProvision.PERMIT
} == true
return ConsentEvaluation(ttpConsentStatus, consentGiven)
}
}
data class ConsentEvaluation(private val ttpConsentStatus: TtpConsentStatus, private val consentGiven: Boolean) {
/**
* Checks if any required consent is present
*/
fun hasConsent(): Boolean {
return consentGiven
}
/**
* Returns the consent status
*/
fun getStatus(): TtpConsentStatus {
if (ttpConsentStatus == TtpConsentStatus.UNKNOWN_CHECK_FILE) {
// in case ttp check is disabled - we propagate rejected status anyway
return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED
}
return ttpConsentStatus
}
}

View File

@@ -23,9 +23,9 @@ import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory
@@ -34,6 +34,7 @@ import org.springframework.kafka.listener.MessageListener
class KafkaInputListener(
private val requestProcessor: RequestProcessor,
private val consentEvaluator: ConsentEvaluator,
private val objectMapper: ObjectMapper
) : MessageListener<String, String> {
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
@@ -70,8 +71,7 @@ class KafkaInputListener(
RequestId("")
}
// TODO: Use MV Consent for now - needs to be replaced with proper consent evaluation
if (mtbFile.metadata.modelProjectConsent.provisions.filter { it.type == ConsentProvision.PERMIT }.isNotEmpty()) {
if (consentEvaluator.check(mtbFile).hasConsent()) {
logger.debug("Accepted MTB File for processing")
if (requestId.isBlank()) {
requestProcessor.processMtbFile(mtbFile)

View File

@@ -21,7 +21,7 @@ package dev.dnpm.etl.processor.input
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.consent.IGetConsent
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
@@ -34,9 +34,8 @@ import org.springframework.web.bind.annotation.*
@RequestMapping(path = ["mtbfile", "mtb"])
class MtbFileRestController(
private val requestProcessor: RequestProcessor,
private val iGetConsent: IGetConsent
private val consentEvaluator: ConsentEvaluator
) {
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
@GetMapping
@@ -46,8 +45,15 @@ class MtbFileRestController(
@PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE])
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
val consentEvaluation = consentEvaluator.check(mtbFile)
if (consentEvaluation.hasConsent()) {
logger.debug("Accepted MTB File (DNPM V2) for processing")
requestProcessor.processMtbFile(mtbFile)
} else {
logger.debug("Accepted MTB File (DNPM V2) and process deletion")
val patientId = PatientId(mtbFile.patient.id)
requestProcessor.processDeletion(patientId, consentEvaluation.getStatus())
}
return ResponseEntity.accepted().build()
}

View File

@@ -6,9 +6,9 @@ import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.config.GIcsConfigProperties
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.MtbFileConsentService
import dev.dnpm.etl.processor.consent.ConsentDomain
import dev.dnpm.etl.processor.consent.IGetConsent
import dev.dnpm.etl.processor.consent.IConsentService
import dev.dnpm.etl.processor.pseudonym.ensureMetaDataIsInitialized
import dev.pcvolkmer.mv64e.mtb.*
import org.apache.commons.lang3.NotImplementedException
@@ -31,7 +31,7 @@ class ConsentProcessor(
private val gIcsConfigProperties: GIcsConfigProperties,
private val objectMapper: ObjectMapper,
private val fhirContext: FhirContext,
private val consentService: IGetConsent
private val consentService: IConsentService
) {
private var logger: Logger = LoggerFactory.getLogger("ConsentProcessor")
@@ -49,7 +49,7 @@ class ConsentProcessor(
*
*/
fun consentGatedCheckAndTryEmbedding(mtbFile: Mtb): Boolean {
if (consentService is ConsentByMtbFile) {
if (consentService is MtbFileConsentService) {
// consent check is disabled
return true
}
@@ -70,7 +70,7 @@ class ConsentProcessor(
* broad consent
*/
val broadConsent = consentService.getConsent(
personIdentifierValue, requestDate, ConsentDomain.BroadConsent
personIdentifierValue, requestDate, ConsentDomain.BROAD_CONSENT
)
val broadConsentHasBeenAsked = !broadConsent.entry.isEmpty()
@@ -78,7 +78,7 @@ class ConsentProcessor(
if (!broadConsentHasBeenAsked) return false
val genomeDeConsent = consentService.getConsent(
personIdentifierValue, requestDate, ConsentDomain.Modelvorhaben64e
personIdentifierValue, requestDate, ConsentDomain.MODELLVORHABEN_64E
)
addGenomeDbProvisions(mtbFile, genomeDeConsent)
@@ -88,11 +88,11 @@ class ConsentProcessor(
embedBroadConsentResources(mtbFile, broadConsent)
val broadConsentStatus = getProvisionTypeByPolicyCode(
broadConsent, requestDate, ConsentDomain.BroadConsent
broadConsent, requestDate, ConsentDomain.BROAD_CONSENT
)
val genomDeSequencingStatus = getProvisionTypeByPolicyCode(
genomeDeConsent, requestDate, ConsentDomain.Modelvorhaben64e
genomeDeConsent, requestDate, ConsentDomain.MODELLVORHABEN_64E
)
if (Consent.ConsentProvisionType.NULL == broadConsentStatus) {
@@ -204,10 +204,10 @@ class ConsentProcessor(
): Consent.ConsentProvisionType {
val code: String?
val system: String?
if (ConsentDomain.BroadConsent == consentDomain) {
if (ConsentDomain.BROAD_CONSENT == consentDomain) {
code = gIcsConfigProperties.broadConsentPolicyCode
system = gIcsConfigProperties.broadConsentPolicySystem
} else if (ConsentDomain.Modelvorhaben64e == consentDomain) {
} else if (ConsentDomain.MODELLVORHABEN_64E == consentDomain) {
code = gIcsConfigProperties.genomeDePolicyCode
system = gIcsConfigProperties.genomeDePolicySystem
} else {

View File

@@ -1,85 +1,112 @@
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 dev.dnpm.etl.processor.config.GIcsConfigProperties;
import java.time.Instant;
import java.util.Date;
import org.hl7.fhir.r4.model.BooleanType;
import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.OperationOutcome;
import org.hl7.fhir.r4.model.*;
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.hl7.fhir.r4.model.StringType;
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.retry.support.RetryTemplate;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.util.Date;
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.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@ContextConfiguration(classes = {AppConfiguration.class, ObjectMapper.class})
@TestPropertySource(properties = {"app.consent.gics.enabled=true",
"app.consent.gics.uri=http://localhost:8090/ttp-fhir/fhir/gics"})
@TestPropertySource(properties = {
"app.consent.service=gics",
"app.consent.gics.uri=http://localhost:8090/ttp-fhir/fhir/gics"
})
@RestClientTest
public class GicsConsentServiceTest {
class GicsConsentServiceTest {
static final String GICS_BASE_URI = "http://localhost:8090/ttp-fhir/fhir/gics";
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;
@Autowired
GIcsConfigProperties gIcsConfigProperties;
GicsConsentService gicsConsentService;
@BeforeEach
public void setUp() {
mockRestServiceServer = MockRestServiceServer.createServer(appConfiguration.restTemplate());
void setUp(
@Autowired AppFhirConfig appFhirConfig,
@Autowired GIcsConfigProperties gIcsConfigProperties
) {
this.appFhirConfig = appFhirConfig;
this.gIcsConfigProperties = gIcsConfigProperties;
var restTemplate = new RestTemplate();
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate);
this.gicsConsentService = new GicsConsentService(
this.gIcsConfigProperties,
RetryTemplate.builder().maxAttempts(1).build(),
restTemplate,
this.appFhirConfig
);
}
@Test
void getTtpBroadConsentStatus() {
final Parameters responseConsented = new Parameters().addParameter(
new ParametersParameterComponent().setName("consented")
.setValue(new BooleanType().setValue(true)));
void shouldReturnTtpBroadConsentStatus() {
final Parameters consentedResponse = 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));
mockRestServiceServer
.expect(
requestTo(
"http://localhost:8090/ttp-fhir/fhir/gics" + GicsConsentService.IS_CONSENTED_ENDPOINT)
)
.andRespond(
withSuccess(
appFhirConfig.fhirContext().newJsonParser().encodeResourceToString(consentedResponse),
MediaType.APPLICATION_JSON
)
);
var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456");
assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_GIVEN);
}
@Test
void consentRevoced() {
final Parameters responseRevoced = new Parameters().addParameter(
new ParametersParameterComponent().setName("consented")
.setValue(new BooleanType().setValue(false)));
void shouldReturnRevokedConsent() {
final Parameters revokedResponse = 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));
mockRestServiceServer
.expect(
requestTo(
"http://localhost:8090/ttp-fhir/fhir/gics" + GicsConsentService.IS_CONSENTED_ENDPOINT)
)
.andRespond(
withSuccess(
appFhirConfig.fhirContext().newJsonParser().encodeResourceToString(revokedResponse),
MediaType.APPLICATION_JSON)
);
var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456");
assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED);
@@ -87,15 +114,39 @@ public class GicsConsentServiceTest {
@Test
void gicsParameterInvalid() {
final OperationOutcome responseErrorOutcome = new OperationOutcome().addIssue(
new OperationOutcomeIssueComponent().setSeverity(IssueSeverity.ERROR)
.setCode(IssueType.PROCESSING).setDiagnostics("Invalid policy parameter..."));
void shouldReturnInvalidParameterResponse() {
final OperationOutcome responseWithErrorOutcome = 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));
mockRestServiceServer
.expect(
requestTo(GICS_BASE_URI + GicsConsentService.IS_CONSENTED_ENDPOINT)
)
.andRespond(
withSuccess(
appFhirConfig.fhirContext().newJsonParser().encodeResourceToString(responseWithErrorOutcome),
MediaType.APPLICATION_JSON
)
);
var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456");
assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK);
}
@Test
void shouldReturnRequestError() {
mockRestServiceServer
.expect(
requestTo(GICS_BASE_URI + GicsConsentService.IS_CONSENTED_ENDPOINT)
)
.andRespond(
withServerError()
);
var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456");
assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK);
@@ -103,20 +154,27 @@ public class GicsConsentServiceTest {
@Test
void buildRequestParameterCurrentPolicyStatesForPersonTest() {
String pid = "12345678";
var result = GicsConsentService.buildRequestParameterCurrentPolicyStatesForPerson(
gIcsConfigProperties, pid, Date.from(Instant.now()),
gIcsConfigProperties.getGenomDeConsentDomainName());
var result = gicsConsentService
.buildRequestParameterCurrentPolicyStatesForPerson(
pid,
Date.from(Instant.now()),
ConsentDomain.MODELLVORHABEN_64E
);
assertThat(result.getParameter().size()).as("should contain 3 parameter resources")
.isEqualTo(3);
assertThat(result.getParameter())
.as("should contain 3 parameter resources")
.hasSize(3);
assertThat(((StringType) result.getParameter("domain").getValue()).getValue()).isEqualTo(
gIcsConfigProperties.getGenomDeConsentDomainName());
assertThat(
((Identifier) result.getParameter("personIdentifier").getValue()).getValue()).isEqualTo(
pid);
assertThat(((StringType) result.getParameter("domain").getValue()).getValue())
.isEqualTo(
gIcsConfigProperties.getGenomDeConsentDomainName()
);
assertThat(((Identifier) result.getParameter("personIdentifier").getValue()).getValue())
.isEqualTo(
pid
);
}

View File

@@ -0,0 +1,287 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 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.consent
import dev.dnpm.etl.processor.ArgProvider
import dev.pcvolkmer.mv64e.mtb.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.ArgumentsSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.whenever
import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class)
class Dnpm21BasedConsentEvaluatorTest {
@Nested
inner class WithGicsConsentEnabled {
lateinit var consentService: GicsConsentService
lateinit var consentEvaluator: ConsentEvaluator
@BeforeEach
fun setUp(
@Mock consentService: GicsConsentService
) {
this.consentService = consentService
this.consentEvaluator = ConsentEvaluator(consentService)
}
@ParameterizedTest
@ArgumentsSource(WithGicsMtbFileProvider::class)
fun test(
mtbFile: Mtb,
ttpConsentStatus: TtpConsentStatus,
expectedConsentEvaluation: ConsentEvaluation
) {
whenever(consentService.getTtpBroadConsentStatus(anyString())).thenReturn(
ttpConsentStatus
)
assertThat(consentEvaluator.check(mtbFile)).isEqualTo(expectedConsentEvaluation)
}
}
@Nested
inner class WithFileConsentOnly {
lateinit var consentService: MtbFileConsentService
lateinit var consentEvaluator: ConsentEvaluator
@BeforeEach
fun setUp() {
this.consentService = MtbFileConsentService()
this.consentEvaluator = ConsentEvaluator(consentService)
}
@ParameterizedTest
@ArgumentsSource(MtbFileProvider::class)
fun test(mtbFile: Mtb, expectedConsentEvaluation: ConsentEvaluation) {
assertThat(consentEvaluator.check(mtbFile)).isEqualTo(expectedConsentEvaluation)
}
}
// Util classes
class WithGicsMtbFileProvider : ArgProvider(
// Has file ModelProjectConsent and broad consent => consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.BROAD_CONSENT_GIVEN,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_GIVEN, true)
),
// Has file ModelProjectConsent and broad consent missing => no consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.BROAD_CONSENT_MISSING,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_MISSING, false)
),
// Has file ModelProjectConsent and broad consent missing or rejected => no consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED, false)
),
// Has file ModelProjectConsent and MV consent => consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT, true)
),
// Has file ModelProjectConsent and MV consent rejected => no consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED, false)
),
// Has file ModelProjectConsent and MV consent missing => no consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.GENOM_DE_CONSENT_MISSING,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_CONSENT_MISSING, false)
),
// Has file ModelProjectConsent and no broad consent result => consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.UNKNOWN_CHECK_FILE,
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, true)
),
// Has file ModelProjectConsent and failed to ask => no consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
TtpConsentStatus.FAILED_TO_ASK,
ConsentEvaluation(TtpConsentStatus.FAILED_TO_ASK, false)
),
// File ModelProjectConsent rejected and broad consent => consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.BROAD_CONSENT_GIVEN,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_GIVEN, true)
),
// File ModelProjectConsent rejected and broad consent missing => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.BROAD_CONSENT_MISSING,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_MISSING, false)
),
// File ModelProjectConsent rejected and broad consent missing or rejected => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED,
ConsentEvaluation(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED, false)
),
// File ModelProjectConsent rejected and MV consent => consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT, true)
),
// File ModelProjectConsent rejected and MV consent rejected => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED, false)
),
// File ModelProjectConsent rejected and MV consent missing => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.GENOM_DE_CONSENT_MISSING,
ConsentEvaluation(TtpConsentStatus.GENOM_DE_CONSENT_MISSING, false)
),
// File ModelProjectConsent rejected and no broad consent result => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.UNKNOWN_CHECK_FILE,
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
),
// File ModelProjectConsent rejected and failed to ask => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
TtpConsentStatus.FAILED_TO_ASK,
ConsentEvaluation(TtpConsentStatus.FAILED_TO_ASK, false)
)
) {
companion object {
fun buildMtb(consentProvision: ConsentProvision): Mtb {
return Mtb.builder()
.patient(
Patient.builder().id("TEST_12345678")
.birthDate(Date.from(Instant.parse("2000-08-08T12:34:56Z"))).gender(
GenderCoding.builder().code(GenderCodingCode.MALE).build()
).build()
)
.metadata(
MvhMetadata.builder().modelProjectConsent(
ModelProjectConsent.builder().provisions(
listOf(
Provision.builder().date(Date()).type(consentProvision)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
).build()
)
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder().id("1")
.patient(Reference.builder().id("TEST_12345678").build())
.build()
)
)
.build()
}
}
}
class MtbFileProvider : ArgProvider(
// Has file consent => consent given
Arguments.of(
buildMtb(ConsentProvision.PERMIT),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, true)
),
// File consent rejected => no consent given
Arguments.of(
buildMtb(ConsentProvision.DENY),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
),
// policy REIDENTIFICATION has no effect on ConsentEvaluation
Arguments.of(
buildMtb(ModelProjectConsentPurpose.REIDENTIFICATION, ConsentProvision.DENY),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
), Arguments.of(
buildMtb(ModelProjectConsentPurpose.REIDENTIFICATION, ConsentProvision.PERMIT),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
),
// policy CASE_IDENTIFICATION has no effect on ConsentEvaluation
Arguments.of(
buildMtb(ModelProjectConsentPurpose.CASE_IDENTIFICATION, ConsentProvision.DENY),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
), Arguments.of(
buildMtb(ModelProjectConsentPurpose.CASE_IDENTIFICATION, ConsentProvision.PERMIT),
ConsentEvaluation(TtpConsentStatus.UNKNOWN_CHECK_FILE, false)
)
) {
companion object {
fun buildMtb(consentProvision: ConsentProvision): Mtb {
return buildMtb(ModelProjectConsentPurpose.SEQUENCING, consentProvision)
}
fun buildMtb(
policy: ModelProjectConsentPurpose,
consentProvision: ConsentProvision
): Mtb {
return Mtb.builder()
.patient(
Patient.builder().id("TEST_12345678")
.birthDate(Date.from(Instant.parse("2000-08-08T12:34:56Z"))).gender(
GenderCoding.builder().code(GenderCodingCode.MALE).build()
).build()
)
.metadata(
MvhMetadata.builder().modelProjectConsent(
ModelProjectConsent.builder().provisions(
listOf(
Provision.builder().date(Date()).type(consentProvision)
.purpose(policy).build()
)
).build()
).build()
)
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder().id("1")
.patient(Reference.builder().id("TEST_12345678").build())
.build()
)
)
.build()
}
}
}
}

View File

@@ -18,3 +18,14 @@
*/
package dev.dnpm.etl.processor
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.ArgumentsProvider
import java.util.stream.Stream
open class ArgProvider(vararg val data: Arguments) : ArgumentsProvider {
override fun provideArguments(
context: ExtensionContext?
): Stream<out Arguments> = Stream.of(*data)
}

View File

@@ -20,8 +20,10 @@
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.consent.ConsentEvaluation
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.*
import org.apache.kafka.clients.consumer.ConsumerRecord
@@ -40,21 +42,32 @@ import java.util.*
class KafkaInputListenerTest {
private lateinit var requestProcessor: RequestProcessor
private lateinit var consentEvaluator: ConsentEvaluator
private lateinit var objectMapper: ObjectMapper
private lateinit var kafkaInputListener: KafkaInputListener
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor,
@Mock consentEvaluator: ConsentEvaluator,
) {
this.requestProcessor = requestProcessor
this.consentEvaluator = consentEvaluator
this.objectMapper = ObjectMapper()
this.kafkaInputListener = KafkaInputListener(requestProcessor, objectMapper)
this.kafkaInputListener = KafkaInputListener(requestProcessor, consentEvaluator, objectMapper)
}
@Test
fun shouldProcessMtbFileRequest() {
whenever(consentEvaluator.check(any())).thenReturn(
ConsentEvaluation(
TtpConsentStatus.BROAD_CONSENT_GIVEN,
true
)
)
val mtbFile = Mtb.builder()
.patient(Patient.builder().id("DUMMY_12345678").build())
.metadata(
@@ -64,7 +77,10 @@ class KafkaInputListenerTest {
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.PERMIT).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
listOf(
Provision.builder().type(ConsentProvision.PERMIT)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
)
.build()
@@ -86,6 +102,13 @@ class KafkaInputListenerTest {
@Test
fun shouldProcessDeleteRequest() {
whenever(consentEvaluator.check(any())).thenReturn(
ConsentEvaluation(
TtpConsentStatus.BROAD_CONSENT_GIVEN,
false
)
)
val mtbFile = Mtb.builder()
.patient(Patient.builder().id("DUMMY_12345678").build())
.metadata(
@@ -95,7 +118,10 @@ class KafkaInputListenerTest {
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.DENY).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
listOf(
Provision.builder().type(ConsentProvision.DENY)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
)
.build()
@@ -120,6 +146,13 @@ class KafkaInputListenerTest {
@Test
fun shouldProcessMtbFileRequestWithExistingRequestId() {
whenever(consentEvaluator.check(any())).thenReturn(
ConsentEvaluation(
TtpConsentStatus.BROAD_CONSENT_GIVEN,
true
)
)
val mtbFile = Mtb.builder()
.patient(Patient.builder().id("DUMMY_12345678").build())
.metadata(
@@ -129,7 +162,10 @@ class KafkaInputListenerTest {
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.PERMIT).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
listOf(
Provision.builder().type(ConsentProvision.PERMIT)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
)
.build()
@@ -158,6 +194,13 @@ class KafkaInputListenerTest {
@Test
fun shouldProcessDeleteRequestWithExistingRequestId() {
whenever(consentEvaluator.check(any())).thenReturn(
ConsentEvaluation(
TtpConsentStatus.BROAD_CONSENT_GIVEN,
false
)
)
val mtbFile = Mtb.builder()
.patient(Patient.builder().id("DUMMY_12345678").build())
.metadata(
@@ -167,7 +210,10 @@ class KafkaInputListenerTest {
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.DENY).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
listOf(
Provision.builder().type(ConsentProvision.DENY)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
)
.build()
@@ -208,7 +254,10 @@ class KafkaInputListenerTest {
ModelProjectConsent
.builder()
.provisions(
listOf(Provision.builder().type(ConsentProvision.DENY).purpose(ModelProjectConsentPurpose.SEQUENCING).build())
listOf(
Provision.builder().type(ConsentProvision.DENY)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
)
.build()

View File

@@ -20,23 +20,34 @@
package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.ArgProvider
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.consent.GicsConsentService
import dev.dnpm.etl.processor.consent.ConsentEvaluation
import dev.dnpm.etl.processor.consent.ConsentEvaluator
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import dev.pcvolkmer.mv64e.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.Arguments
import org.junit.jupiter.params.provider.ArgumentsSource
import org.mockito.Mock
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.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class)
class MtbFileRestControllerTest {
@@ -49,22 +60,31 @@ class MtbFileRestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
private lateinit var consentEvaluator: ConsentEvaluator
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor,
@Mock gicsConsentService: GicsConsentService
@Mock consentEvaluator: ConsentEvaluator
) {
this.requestProcessor = requestProcessor
this.consentEvaluator = consentEvaluator
val controller = MtbFileRestController(
requestProcessor,
gicsConsentService
consentEvaluator
)
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
}
@Test
fun shouldRespondPostRequest() {
whenever(consentEvaluator.check(any())).thenReturn(
ConsentEvaluation(
TtpConsentStatus.BROAD_CONSENT_GIVEN,
true
)
)
val mtbFileContent =
ClassPathResource("mv64e-mtb-fake-patient.json").inputStream.readAllBytes().toString(Charsets.UTF_8)
@@ -80,5 +100,127 @@ class MtbFileRestControllerTest {
verify(requestProcessor, times(1)).processMtbFile(any<Mtb>())
}
@ParameterizedTest
@ArgumentsSource(Dnpm21MtbFile::class)
fun shouldProcessPostRequest(mtb: Mtb, broadConsent: TtpConsentStatus, shouldProcess: String) {
whenever(consentEvaluator.check(any<Mtb>())).thenReturn(
ConsentEvaluation(
broadConsent,
shouldProcess == "process"
)
)
mockMvc.post("/mtbfile") {
content = objectMapper.writeValueAsString(mtb)
contentType = CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
}.andExpect {
status {
isAccepted()
}
}
if (shouldProcess == "process") {
verify(requestProcessor, times(1)).processMtbFile(any<Mtb>())
} else {
verify(requestProcessor, times(1)).processDeletion(
anyValueClass(),
org.mockito.kotlin.eq(broadConsent)
)
}
}
@Test
fun shouldProcessDeleteRequest() {
mockMvc.delete("/mtbfile/TEST_12345678").andExpect {
status {
isAccepted()
}
}
verify(requestProcessor, times(1)).processDeletion(
anyValueClass(),
org.mockito.kotlin.eq(TtpConsentStatus.UNKNOWN_CHECK_FILE)
)
verify(consentEvaluator, times(0)).check(any<Mtb>())
}
}
}
class Dnpm21MtbFile : ArgProvider(
// No Metadata and no broad consent => delete
Arguments.of(
buildMtb(null),
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED,
"delete"
),
// No Metadata and broad consent given => process
Arguments.of(
buildMtb(null),
TtpConsentStatus.BROAD_CONSENT_GIVEN,
"process"
),
// No model project consent and no broad consent => delete
Arguments.of(
buildMtb(MvhMetadata.builder().modelProjectConsent(ModelProjectConsent.builder().build()).build()),
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED,
"delete"
),
// No model project consent and broad consent given => process
Arguments.of(
buildMtb(MvhMetadata.builder().modelProjectConsent(ModelProjectConsent.builder().build()).build()),
TtpConsentStatus.BROAD_CONSENT_GIVEN,
"process"
),
// Model project consent given and no broad consent => process
Arguments.of(
buildMtb(
MvhMetadata.builder().modelProjectConsent(
ModelProjectConsent.builder().provisions(
listOf(
Provision.builder().date(Date()).type(ConsentProvision.PERMIT)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
).build()
),
TtpConsentStatus.UNKNOWN_CHECK_FILE,
"process"
),
// Model project consent given and broad consent given => process
Arguments.of(
buildMtb(
MvhMetadata.builder().modelProjectConsent(
ModelProjectConsent.builder().provisions(
listOf(
Provision.builder().date(Date()).type(ConsentProvision.PERMIT)
.purpose(ModelProjectConsentPurpose.SEQUENCING).build()
)
).build()
).build()
),
TtpConsentStatus.BROAD_CONSENT_GIVEN,
"process"
)
) {
companion object {
fun buildMtb(metadata: MvhMetadata?): Mtb {
return Mtb.builder()
.patient(
Patient.builder().id("TEST_12345678")
.birthDate(Date.from(Instant.parse("2000-08-08T12:34:56Z"))).gender(
GenderCoding.builder().code(GenderCodingCode.MALE).build()
).build()
)
.metadata(metadata)
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder().id("1")
.patient(Reference.builder().id("TEST_12345678").build())
.build()
)
)
.build()
}
}
}

View File

@@ -24,7 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.config.GIcsConfigProperties
import dev.dnpm.etl.processor.config.JacksonConfig
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.MtbFileConsentService
import dev.dnpm.etl.processor.services.ConsentProcessor
import dev.dnpm.etl.processor.services.ConsentProcessorTest
import dev.pcvolkmer.mv64e.mtb.*
@@ -95,7 +95,7 @@ class ExtensionsTest {
gIcsConfigProperties,
JacksonConfig().objectMapper(),
FhirContext.forR4(),
ConsentByMtbFile()
MtbFileConsentService()
).embedBroadConsentResources(mtbFile, bundle)
}

View File

@@ -7,7 +7,8 @@ import dev.dnpm.etl.processor.config.GIcsConfigProperties
import dev.dnpm.etl.processor.config.JacksonConfig
import dev.dnpm.etl.processor.consent.ConsentDomain
import dev.dnpm.etl.processor.consent.GicsConsentService
import dev.pcvolkmer.mv64e.mtb.*
import dev.pcvolkmer.mv64e.mtb.Mtb
import dev.pcvolkmer.mv64e.mtb.Patient
import org.assertj.core.api.Assertions.assertThat
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.CodeableConcept
@@ -46,7 +47,7 @@ class ConsentProcessorTest {
@Mock gicsConsentService: GicsConsentService,
) {
this.gIcsConfigProperties = GIcsConfigProperties(null, null, null)
this.gIcsConfigProperties = GIcsConfigProperties("https://gics.example.com")
val jacksonConfig = JacksonConfig()
this.objectMapper = jacksonConfig.objectMapper()
this.fhirContext = JacksonConfig.fhirContext()
@@ -67,10 +68,10 @@ class ConsentProcessorTest {
assertThat(consentProcessor.toString()).isNotNull
// prep gICS response
doAnswer { getDummyBroadConsentBundle() }.whenever(gicsConsentService)
.getConsent(any(), any(), eq(ConsentDomain.BroadConsent))
.getConsent(any(), any(), eq(ConsentDomain.BROAD_CONSENT))
doAnswer { Bundle() }.whenever(gicsConsentService)
.getConsent(any(), any(), eq(ConsentDomain.Modelvorhaben64e))
.getConsent(any(), any(), eq(ConsentDomain.MODELLVORHABEN_64E))
val inputMtb = Mtb.builder()
.patient(Patient.builder().id("d611d429-5003-11f0-a144-661e92ac9503").build()).build()