diff --git a/api/src/main/java/care/smith/fts/api/ConsentedPatientBundle.java b/api/src/main/java/care/smith/fts/api/ConsentedPatientBundle.java index 1122d365c..2b50e3c6b 100644 --- a/api/src/main/java/care/smith/fts/api/ConsentedPatientBundle.java +++ b/api/src/main/java/care/smith/fts/api/ConsentedPatientBundle.java @@ -2,4 +2,14 @@ import org.hl7.fhir.r4.model.Bundle; -public record ConsentedPatientBundle(Bundle bundle, ConsentedPatient consentedPatient) {} +/** + * A bundle of FHIR resources for a consented patient. + * + * @param bundle the FHIR bundle containing patient resources + * @param consentedPatient the consented patient + * @param patientResourceId the FHIR resource ID of the Patient (e.g., "DGXCRR3SDVNIEB2R"), which + * may differ from the patient identifier. This is needed for compartment membership checking + * because resource references use the resource ID, not the patient identifier. + */ +public record ConsentedPatientBundle( + Bundle bundle, ConsentedPatient consentedPatient, String patientResourceId) {} diff --git a/api/src/test/java/care/smith/fts/api/ConsentedPatientBundleTest.java b/api/src/test/java/care/smith/fts/api/ConsentedPatientBundleTest.java index d38267fce..3ff6a2b5f 100644 --- a/api/src/test/java/care/smith/fts/api/ConsentedPatientBundleTest.java +++ b/api/src/test/java/care/smith/fts/api/ConsentedPatientBundleTest.java @@ -7,6 +7,6 @@ class ConsentedPatientBundleTest { @Test void nullsAllowed() { - assertThatNoException().isThrownBy(() -> new ConsentedPatientBundle(null, null)); + assertThatNoException().isThrownBy(() -> new ConsentedPatientBundle(null, null, null)); } } diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/DefaultTransferProcessRunner.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/DefaultTransferProcessRunner.java index c3d640356..43aab2da5 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/DefaultTransferProcessRunner.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/DefaultTransferProcessRunner.java @@ -157,7 +157,13 @@ private Flux sendBundles(Flux deidentification) { return deidentification .flatMap(b -> process.bundleSender().send(b), config.maxSendConcurrency) .doOnNext(b -> status.updateAndGet(TransferProcessStatus::incSentBundles)) - .onErrorContinue((e, r) -> status.updateAndGet(TransferProcessStatus::incSkippedBundles)); + .onErrorContinue( + (e, r) -> { + log.warn( + "[Process {}] Bundle skipped due to error: {}", processId(), e.getMessage()); + log.debug("[Process {}] Bundle skip stack trace", processId(), e); + status.updateAndGet(TransferProcessStatus::incSkippedBundles); + }); } private void onComplete() { diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStep.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStep.java index f5678ab6e..135c374b0 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStep.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStep.java @@ -10,11 +10,12 @@ import care.smith.fts.api.cda.Deidentificator; import care.smith.fts.cda.services.deidentifhir.DeidentifhirUtils; import care.smith.fts.cda.services.deidentifhir.IdatScraper; +import care.smith.fts.cda.services.deidentifhir.IdatScraper.GatheredIds; +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; import care.smith.fts.util.error.TransferProcessException; import care.smith.fts.util.tca.*; import io.micrometer.core.instrument.MeterRegistry; import java.time.Duration; -import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -32,6 +33,8 @@ class DeidentifhirStep implements Deidentificator { private final com.typesafe.config.Config deidentifhirConfig; private final com.typesafe.config.Config scraperConfig; private final MeterRegistry meterRegistry; + private final PatientCompartmentService patientCompartmentService; + private final boolean enableCompartmentNamespacing; public DeidentifhirStep( WebClient tcaClient, @@ -40,7 +43,9 @@ public DeidentifhirStep( DateShiftPreserve preserve, com.typesafe.config.Config deidentifhirConfig, com.typesafe.config.Config scraperConfig, - MeterRegistry meterRegistry) { + MeterRegistry meterRegistry, + PatientCompartmentService patientCompartmentService, + boolean enableCompartmentNamespacing) { this.tcaClient = tcaClient; this.domains = domains; this.maxDateShift = maxDateShift; @@ -48,12 +53,20 @@ public DeidentifhirStep( this.deidentifhirConfig = deidentifhirConfig; this.scraperConfig = scraperConfig; this.meterRegistry = meterRegistry; + this.patientCompartmentService = patientCompartmentService; + this.enableCompartmentNamespacing = enableCompartmentNamespacing; } @Override public Mono deidentify(ConsentedPatientBundle bundle) { var patient = bundle.consentedPatient(); - var idatScraper = new IdatScraper(scraperConfig, patient); + var idatScraper = + new IdatScraper( + scraperConfig, + patient, + patientCompartmentService, + bundle.patientResourceId(), + enableCompartmentNamespacing); var ids = idatScraper.gatherIDs(bundle.bundle()); return !ids.isEmpty() @@ -76,12 +89,21 @@ public Mono deidentify(ConsentedPatientBundle bundle) { } private Mono fetchTransportMapping( - ConsentedPatient patient, Set ids) { + ConsentedPatient patient, GatheredIds ids) { var request = new TransportMappingRequest( - patient.id(), patient.patientIdentifierSystem(), ids, domains, maxDateShift, preserve); + patient.id(), + patient.patientIdentifierSystem(), + ids.compartment(), + ids.nonCompartment(), + domains, + maxDateShift, + preserve); - log.trace("Fetch transport mapping for {} IDs", ids.size()); + log.trace( + "Fetch transport mapping for {} compartment + {} non-compartment IDs", + ids.compartment().size(), + ids.nonCompartment().size()); return tcaClient .post() .uri("/api/v2/cd/transport-mapping") diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepConfig.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepConfig.java index 3b821a5b3..d0ac72e7b 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepConfig.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepConfig.java @@ -12,19 +12,23 @@ public record DeidentifhirStepConfig( Duration maxDateShift, File deidentifhirConfig, File scraperConfig, - DateShiftPreserve dateShiftPreserve) { + DateShiftPreserve dateShiftPreserve, + Boolean enableCompartmentNamespacing) { public DeidentifhirStepConfig( TCAConfig trustCenterAgent, Duration maxDateShift, File deidentifhirConfig, File scraperConfig, - DateShiftPreserve dateShiftPreserve) { + DateShiftPreserve dateShiftPreserve, + Boolean enableCompartmentNamespacing) { this.trustCenterAgent = trustCenterAgent; this.maxDateShift = maxDateShift; this.deidentifhirConfig = deidentifhirConfig; this.scraperConfig = scraperConfig; this.dateShiftPreserve = Optional.ofNullable(dateShiftPreserve).orElse(DateShiftPreserve.NONE); + this.enableCompartmentNamespacing = + Optional.ofNullable(enableCompartmentNamespacing).orElse(false); } public record TCAConfig(HttpClientConfig server, TcaDomains domains) {} diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepFactory.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepFactory.java index c69e1483d..77568bba6 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepFactory.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/DeidentifhirStepFactory.java @@ -4,6 +4,7 @@ import static java.util.Objects.requireNonNull; import care.smith.fts.api.cda.Deidentificator; +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; import care.smith.fts.util.WebClientFactory; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.stereotype.Component; @@ -13,10 +14,15 @@ public class DeidentifhirStepFactory implements Deidentificator.Factory select(ConsentedPatient patient) { return pidResolver .resolve(patient) - .flatMapMany(fhirId -> fetchEverything(patient, fhirId)) - .map(b -> new ConsentedPatientBundle(b, patient)); + .flatMapMany( + fhirId -> + fetchEverything(patient, fhirId) + .map(b -> new ConsentedPatientBundle(b, patient, fhirId.getIdPart()))); } private Flux fetchEverything(ConsentedPatient patient, IIdType fhirId) { diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/IdatScraper.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/IdatScraper.java index 4464f25be..931f5bafb 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/IdatScraper.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/IdatScraper.java @@ -7,14 +7,40 @@ import de.ume.deidentifhir.Registry; import de.ume.deidentifhir.util.Handlers; import de.ume.deidentifhir.util.JavaCompat; +import java.util.HashMap; +import java.util.Map; import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Resource; +@Slf4j public class IdatScraper { + + public record GatheredIds(Set compartment, Set nonCompartment) { + public boolean isEmpty() { + return compartment.isEmpty() && nonCompartment.isEmpty(); + } + } + private final Deidentifhir deidentiFHIR; private final ScrapingStorage scrapingStorage; + private final PatientCompartmentService patientCompartmentService; + private final String patientIdentifier; + private final String patientResourceId; + private final boolean enableCompartmentNamespacing; + + public IdatScraper( + Config config, + ConsentedPatient patient, + PatientCompartmentService patientCompartmentService, + String patientResourceId, + boolean enableCompartmentNamespacing) { + this.patientCompartmentService = patientCompartmentService; + this.patientIdentifier = patient.id(); + this.patientResourceId = patientResourceId; + this.enableCompartmentNamespacing = enableCompartmentNamespacing; - public IdatScraper(Config config, ConsentedPatient patient) { var keyCreator = NamespacingReplacementProvider.withNamespacing(patient.id()); scrapingStorage = new ScrapingStorage(keyCreator); @@ -38,12 +64,45 @@ public IdatScraper(Config config, ConsentedPatient patient) { } /** - * Gather all IDs contained in the provided bundle and return them as a Set. + * Gather all IDs contained in the provided bundle and return them separated by compartment. + * + *

Resources in the patient compartment will have IDs prefixed with the patient ID. Resources + * not in the compartment will have IDs without the patient prefix. * - * @return a Set of all IDs gathered in the Resource + * @return a GatheredIds record containing compartment and non-compartment IDs */ - public Set gatherIDs(Resource resource) { - deidentiFHIR.deidentify(resource); - return scrapingStorage.getGatheredIdats(); + public GatheredIds gatherIDs(Bundle bundle) { + // Pre-compute compartment membership for all resources + Map membership = precomputeCompartmentMembership(bundle); + scrapingStorage.setCompartmentMembership(membership); + + deidentiFHIR.deidentify(bundle); + return new GatheredIds( + scrapingStorage.getCompartmentIds(), scrapingStorage.getNonCompartmentIds()); + } + + private Map precomputeCompartmentMembership(Bundle bundle) { + if (!enableCompartmentNamespacing) { + // When disabled, return empty map - ScrapingStorage defaults to all-in-compartment + log.trace("Compartment namespacing disabled, treating all resources as in-compartment"); + return Map.of(); + } + + Map membership = new HashMap<>(); + + log.trace( + "Checking compartment membership with patientResourceId: {} for patient identifier: {}", + patientResourceId, + patientIdentifier); + + for (var entry : bundle.getEntry()) { + Resource r = entry.getResource(); + String key = r.fhirType() + ":" + r.getIdPart(); + boolean inCompartment = + patientCompartmentService.isInPatientCompartment(r, patientResourceId); + membership.put(key, inCompartment); + } + + return membership; } } diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentService.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentService.java new file mode 100644 index 000000000..25f93c943 --- /dev/null +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentService.java @@ -0,0 +1,308 @@ +package care.smith.fts.cda.services.deidentifhir; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Property; +import org.hl7.fhir.r4.model.Reference; +import org.hl7.fhir.r4.model.Resource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +/** + * Checks if a FHIR resource instance is in the patient compartment by examining whether the + * resource's compartment param fields reference the patient. + * + *

A resource is in the patient compartment if ANY of its defined param fields reference the + * patient. For example, ServiceRequest has params ["subject", "performer"], so it's in the + * compartment if either the subject OR the performer references the patient. + */ +@Slf4j +@Component +public class PatientCompartmentService { + + /** + * Maps search parameter names to their corresponding field names. The compartment definition uses + * search parameter names (e.g., "patient"), but resources use field names (e.g., "subject"). + */ + private static final Map> SEARCH_PARAM_TO_FIELD = + Map.ofEntries( + Map.entry("patient", List.of("subject", "patient")), + Map.entry("subject", List.of("subject")), + Map.entry("policy-holder", List.of("policyHolder"))); + + /** + * Maps (resourceType, searchParam) to nested paths for cases where the reference is not at the + * top level. Paths use dot notation (e.g., "participant.actor" means + * resource.participant[].actor). + */ + private static final Map>> NESTED_PATHS = + Map.ofEntries( + Map.entry("Appointment", Map.of("actor", List.of("participant.actor"))), + Map.entry("CareTeam", Map.of("participant", List.of("participant.member"))), + Map.entry("RequestGroup", Map.of("participant", List.of("action.participant.actor"))), + Map.entry("Claim", Map.of("payee", List.of("payee.party"))), + Map.entry("ExplanationOfBenefit", Map.of("payee", List.of("payee.party"))), + Map.entry("Composition", Map.of("attester", List.of("attester.party"))), + Map.entry("MedicationAdministration", Map.of("performer", List.of("performer.actor"))), + Map.entry("Group", Map.of("member", List.of("member.entity"))), + Map.entry("Patient", Map.of("link", List.of("link.other")))); + + private final Map> patientCompartmentParams; + + public PatientCompartmentService(Map> patientCompartmentParams) { + this.patientCompartmentParams = patientCompartmentParams; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CompartmentDefinition(List resource) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResourceEntry(String code, List param) { + public List paramsOrEmpty() { + return param != null ? param : List.of(); + } + } + + /** + * Loads the patient compartment definition from a classpath resource. + * + * @param objectMapper the ObjectMapper to use for JSON parsing + * @param resourcePath the classpath resource path to load from + * @return a map of resource type to compartment params + */ + public static Map> loadCompartmentDefinition( + ObjectMapper objectMapper, String resourcePath) { + try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { + CompartmentDefinition definition = objectMapper.readValue(is, CompartmentDefinition.class); + + if (definition.resource() == null) { + throw new IllegalStateException("Invalid compartment definition: missing resource array"); + } + + return definition.resource().stream() + .collect( + Collectors.toUnmodifiableMap( + ResourceEntry::code, ResourceEntry::paramsOrEmpty, (a, b) -> a)); + } catch (IOException e) { + throw new IllegalStateException("Failed to load patient compartment definition", e); + } + } + + /** + * Checks if a resource is in the patient compartment. + * + * @param resource the FHIR resource to check + * @param patientId the patient ID to check against + * @return true if the resource IS the patient or ANY param field references the patient + */ + public boolean isInPatientCompartment(Resource resource, String patientId) { + String resourceType = resource.fhirType(); + + // Special case: if the resource IS the patient, it's always in the compartment + if ("Patient".equals(resourceType) && patientId.equals(resource.getIdPart())) { + log.trace("Resource Patient/{} IS the patient, in compartment", patientId); + return true; + } + + List params = patientCompartmentParams.getOrDefault(resourceType, List.of()); + + if (params.isEmpty()) { + log.trace("Resource type {} has no compartment params, not in compartment", resourceType); + return false; + } + + for (String param : params) { + if (paramReferencesPatient(resource, param, patientId)) { + log.trace( + "Resource {}/{} is in patient compartment via param '{}'", + resourceType, + resource.getIdPart(), + param); + return true; + } + } + + log.trace( + "Resource {}/{} has no param referencing patient {}", + resourceType, + resource.getIdPart(), + patientId); + return false; + } + + private boolean paramReferencesPatient(Resource resource, String paramName, String patientId) { + List references = getReferencesForParam(resource, paramName); + log.trace( + "Resource {}/{} param '{}' has {} references", + resource.fhirType(), + resource.getIdPart(), + paramName, + references.size()); + return references.stream().anyMatch(ref -> referencesPatient(ref, patientId)); + } + + private List getReferencesForParam(Resource resource, String paramName) { + String resourceType = resource.fhirType(); + + // First, check for nested paths specific to this resource type + List nestedRefs = getReferencesFromNestedPaths(resource, resourceType, paramName); + if (!nestedRefs.isEmpty()) { + return nestedRefs; + } + + // Fall back to top-level field lookup + List fieldNames = SEARCH_PARAM_TO_FIELD.getOrDefault(paramName, List.of(paramName)); + + for (String fieldName : fieldNames) { + try { + Property prop = resource.getNamedProperty(fieldName); + if (prop == null) { + continue; + } + + log.trace( + "Property '{}' (for param '{}') in {}/{} has {} values, types: {}", + fieldName, + paramName, + resource.fhirType(), + resource.getIdPart(), + prop.getValues().size(), + prop.getValues().stream().map(v -> v.getClass().getSimpleName()).toList()); + + List refs = + prop.getValues().stream() + .filter(Reference.class::isInstance) + .map(Reference.class::cast) + .toList(); + if (!refs.isEmpty()) { + return refs; + } + } catch (Exception e) { + log.trace( + "Could not get property '{}' from resource {}: {}", + fieldName, + resource.fhirType(), + e.getMessage()); + } + } + + log.trace( + "No Reference found for param '{}' (tried fields: {}) in resource {}/{}", + paramName, + fieldNames, + resource.fhirType(), + resource.getIdPart()); + return List.of(); + } + + private List getReferencesFromNestedPaths( + Resource resource, String resourceType, String paramName) { + List allRefs = + Optional.ofNullable(NESTED_PATHS.get(resourceType)) + .map(paramPaths -> paramPaths.get(paramName)) + .stream() + .flatMap(List::stream) + .flatMap(path -> traversePath(resource, path).stream()) + .toList(); + + if (!allRefs.isEmpty()) { + log.trace( + "Found {} references via nested path for param '{}' in {}/{}", + allRefs.size(), + paramName, + resourceType, + resource.getIdPart()); + } + return allRefs; + } + + /** + * Traverses a dot-separated path through a resource to find Reference values. Handles both single + * values and lists at each level. + */ + private List traversePath(org.hl7.fhir.r4.model.Base current, String path) { + if (current == null || path == null || path.isEmpty()) { + return List.of(); + } + + String[] parts = path.split("\\.", 2); + String fieldName = parts[0]; + String remainingPath = parts.length > 1 ? parts[1] : null; + + try { + Property prop = current.getNamedProperty(fieldName); + if (prop == null || prop.getValues().isEmpty()) { + return List.of(); + } + + // If this is the last part of the path, extract References + if (remainingPath == null) { + return prop.getValues().stream() + .filter(Reference.class::isInstance) + .map(Reference.class::cast) + .toList(); + } + + // Otherwise, continue traversing each value + List results = new java.util.ArrayList<>(); + for (org.hl7.fhir.r4.model.Base value : prop.getValues()) { + results.addAll(traversePath(value, remainingPath)); + } + return results; + } catch (Exception e) { + log.trace("Error traversing path '{}' from {}: {}", path, current.fhirType(), e.getMessage()); + return List.of(); + } + } + + private boolean referencesPatient(Reference reference, String patientId) { + if (reference.isEmpty()) { + return false; + } + + String refValue = reference.getReference(); + if (refValue == null) { + return false; + } + + log.trace("Checking reference '{}' against patient ID '{}'", refValue, patientId); + + // Handle various reference formats: + // - "Patient/ID" + // - "http://server/fhir/Patient/ID" + // - Full URLs with Patient resource + String patientRef = "Patient/" + patientId; + if (refValue.equals(patientRef) || refValue.endsWith("/" + patientRef)) { + return true; + } + + // Also check if the reference ends with just the patient ID after "Patient/" + return extractIdFromReference(refValue, patientId); + } + + private boolean extractIdFromReference(String refValue, String patientId) { + // Find "Patient/" in the reference and extract the ID that follows + int patientIdx = refValue.lastIndexOf("Patient/"); + if (patientIdx >= 0) { + String id = refValue.substring(patientIdx + "Patient/".length()); + // Remove any trailing path components or query params + int slashIdx = id.indexOf('/'); + if (slashIdx > 0) { + id = id.substring(0, slashIdx); + } + int queryIdx = id.indexOf('?'); + if (queryIdx > 0) { + id = id.substring(0, queryIdx); + } + return id.equals(patientId); + } + return false; + } +} diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorage.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorage.java index 12b7545aa..b5ade203e 100644 --- a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorage.java +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorage.java @@ -4,26 +4,45 @@ import de.ume.deidentifhir.util.IDReplacementProvider; import de.ume.deidentifhir.util.IdentifierValueReplacementProvider; import java.util.HashSet; +import java.util.Map; import java.util.Set; import lombok.Getter; +import lombok.Setter; public class ScrapingStorage implements IDReplacementProvider, IdentifierValueReplacementProvider { - @Getter Set gatheredIdats = new HashSet<>(); + @Getter Set compartmentIds = new HashSet<>(); + @Getter Set nonCompartmentIds = new HashSet<>(); private final KeyCreator namespacingService; + /** + * Map of "ResourceType:id" -> isInCompartment. When set, determines whether to apply patient ID + * prefix to resource IDs. + */ + @Setter private Map compartmentMembership = Map.of(); + public ScrapingStorage(KeyCreator namespacingService) { this.namespacingService = namespacingService; } @Override public String getIDReplacement(String resourceType, String id) { - gatheredIdats.add(namespacingService.getKeyForResourceTypeAndID(resourceType, id)); + String lookupKey = resourceType + ":" + id; + boolean inCompartment = compartmentMembership.getOrDefault(lookupKey, true); + + String key; + if (inCompartment) { + key = namespacingService.getKeyForResourceTypeAndID(resourceType, id); + compartmentIds.add(key); + } else { + key = resourceType + ":" + id; + nonCompartmentIds.add(key); + } return id; } @Override public String getValueReplacement(String system, String value) { - gatheredIdats.add(namespacingService.getKeyForSystemAndValue(system, value)); + compartmentIds.add(namespacingService.getKeyForSystemAndValue(system, value)); return value; } } diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfiguration.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfiguration.java new file mode 100644 index 000000000..38a2eb5e4 --- /dev/null +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfiguration.java @@ -0,0 +1,28 @@ +package care.smith.fts.cda.services.deidentifhir.configuration; + +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class PatientCompartmentServiceConfiguration { + + private static final String COMPARTMENT_DEFINITION_PATH = + "fhir/compartmentdefinition-patient.json"; + + @Bean + public Map> patientCompartmentParams(ObjectMapper objectMapper) { + var resourceTypeToParams = + PatientCompartmentService.loadCompartmentDefinition( + objectMapper, COMPARTMENT_DEFINITION_PATH); + log.info( + "Loaded patient compartment params with {} resource types having compartment params", + resourceTypeToParams.values().stream().filter(params -> !params.isEmpty()).count()); + return resourceTypeToParams; + } +} diff --git a/clinical-domain-agent/src/main/resources/fhir/compartmentdefinition-patient.json b/clinical-domain-agent/src/main/resources/fhir/compartmentdefinition-patient.json new file mode 100644 index 000000000..d6c4b9eca --- /dev/null +++ b/clinical-domain-agent/src/main/resources/fhir/compartmentdefinition-patient.json @@ -0,0 +1,156 @@ +{ + "resourceType" : "CompartmentDefinition", + "id" : "patient", + "url" : "http://hl7.org/fhir/CompartmentDefinition/patient", + "version" : "4.3.0", + "name" : "Base FHIR compartment definition for Patient", + "status" : "draft", + "experimental" : true, + "date" : "2022-05-28T12:47:40+10:00", + "publisher" : "FHIR Project Team", + "description" : "There is an instance of the patient compartment for each patient resource, and the identity of the compartment is the same as the patient.", + "code" : "Patient", + "search" : true, + "resource" : [ + { "code" : "Account", "param" : ["subject"] }, + { "code" : "ActivityDefinition" }, + { "code" : "AdministrableProductDefinition" }, + { "code" : "AdverseEvent", "param" : ["subject"] }, + { "code" : "AllergyIntolerance", "param" : ["patient", "recorder", "asserter"] }, + { "code" : "Appointment", "param" : ["actor"] }, + { "code" : "AppointmentResponse", "param" : ["actor"] }, + { "code" : "AuditEvent", "param" : ["patient"] }, + { "code" : "Basic", "param" : ["patient", "author"] }, + { "code" : "Binary" }, + { "code" : "BiologicallyDerivedProduct" }, + { "code" : "BodyStructure", "param" : ["patient"] }, + { "code" : "Bundle" }, + { "code" : "CapabilityStatement" }, + { "code" : "CarePlan", "param" : ["patient", "performer"] }, + { "code" : "CareTeam", "param" : ["patient", "participant"] }, + { "code" : "CatalogEntry" }, + { "code" : "ChargeItem", "param" : ["subject"] }, + { "code" : "ChargeItemDefinition" }, + { "code" : "Citation" }, + { "code" : "Claim", "param" : ["patient", "payee"] }, + { "code" : "ClaimResponse", "param" : ["patient"] }, + { "code" : "ClinicalImpression", "param" : ["subject"] }, + { "code" : "ClinicalUseDefinition" }, + { "code" : "CodeSystem" }, + { "code" : "Communication", "param" : ["subject", "sender", "recipient"] }, + { "code" : "CommunicationRequest", "param" : ["subject", "sender", "recipient", "requester"] }, + { "code" : "CompartmentDefinition" }, + { "code" : "Composition", "param" : ["subject", "author", "attester"] }, + { "code" : "ConceptMap" }, + { "code" : "Condition", "param" : ["patient", "asserter"] }, + { "code" : "Consent", "param" : ["patient"] }, + { "code" : "Contract" }, + { "code" : "Coverage", "param" : ["policy-holder", "subscriber", "beneficiary", "payor"] }, + { "code" : "CoverageEligibilityRequest", "param" : ["patient"] }, + { "code" : "CoverageEligibilityResponse", "param" : ["patient"] }, + { "code" : "DetectedIssue", "param" : ["patient"] }, + { "code" : "Device" }, + { "code" : "DeviceDefinition" }, + { "code" : "DeviceMetric" }, + { "code" : "DeviceRequest", "param" : ["subject", "performer"] }, + { "code" : "DeviceUseStatement", "param" : ["subject"] }, + { "code" : "DiagnosticReport", "param" : ["subject"] }, + { "code" : "DocumentManifest", "param" : ["subject", "author", "recipient"] }, + { "code" : "DocumentReference", "param" : ["subject", "author"] }, + { "code" : "Encounter", "param" : ["patient"] }, + { "code" : "Endpoint" }, + { "code" : "EnrollmentRequest", "param" : ["subject"] }, + { "code" : "EnrollmentResponse" }, + { "code" : "EpisodeOfCare", "param" : ["patient"] }, + { "code" : "EventDefinition" }, + { "code" : "Evidence" }, + { "code" : "EvidenceReport" }, + { "code" : "EvidenceVariable" }, + { "code" : "ExampleScenario" }, + { "code" : "ExplanationOfBenefit", "param" : ["patient", "payee"] }, + { "code" : "FamilyMemberHistory", "param" : ["patient"] }, + { "code" : "Flag", "param" : ["patient"] }, + { "code" : "Goal", "param" : ["patient"] }, + { "code" : "GraphDefinition" }, + { "code" : "Group", "param" : ["member"] }, + { "code" : "GuidanceResponse" }, + { "code" : "HealthcareService" }, + { "code" : "ImagingStudy", "param" : ["patient"] }, + { "code" : "Immunization", "param" : ["patient"] }, + { "code" : "ImmunizationEvaluation", "param" : ["patient"] }, + { "code" : "ImmunizationRecommendation", "param" : ["patient"] }, + { "code" : "ImplementationGuide" }, + { "code" : "Ingredient" }, + { "code" : "InsurancePlan" }, + { "code" : "Invoice", "param" : ["subject", "patient", "recipient"] }, + { "code" : "Library" }, + { "code" : "Linkage" }, + { "code" : "List", "param" : ["subject", "source"] }, + { "code" : "Location" }, + { "code" : "ManufacturedItemDefinition" }, + { "code" : "Measure" }, + { "code" : "MeasureReport", "param" : ["patient"] }, + { "code" : "Media", "param" : ["subject"] }, + { "code" : "Medication" }, + { "code" : "MedicationAdministration", "param" : ["patient", "performer", "subject"] }, + { "code" : "MedicationDispense", "param" : ["subject", "patient", "receiver"] }, + { "code" : "MedicationKnowledge" }, + { "code" : "MedicationRequest", "param" : ["subject"] }, + { "code" : "MedicationStatement", "param" : ["subject"] }, + { "code" : "MedicinalProductDefinition" }, + { "code" : "MessageDefinition" }, + { "code" : "MessageHeader" }, + { "code" : "MolecularSequence", "param" : ["patient"] }, + { "code" : "NamingSystem" }, + { "code" : "NutritionOrder", "param" : ["patient"] }, + { "code" : "NutritionProduct" }, + { "code" : "Observation", "param" : ["subject", "performer"] }, + { "code" : "ObservationDefinition" }, + { "code" : "OperationDefinition" }, + { "code" : "OperationOutcome" }, + { "code" : "Organization" }, + { "code" : "OrganizationAffiliation" }, + { "code" : "PackagedProductDefinition" }, + { "code" : "Patient", "param" : ["link"] }, + { "code" : "PaymentNotice" }, + { "code" : "PaymentReconciliation" }, + { "code" : "Person", "param" : ["patient"] }, + { "code" : "PlanDefinition" }, + { "code" : "Practitioner" }, + { "code" : "PractitionerRole" }, + { "code" : "Procedure", "param" : ["patient", "performer"] }, + { "code" : "Provenance", "param" : ["patient"] }, + { "code" : "Questionnaire" }, + { "code" : "QuestionnaireResponse", "param" : ["subject", "author"] }, + { "code" : "RegulatedAuthorization" }, + { "code" : "RelatedPerson", "param" : ["patient"] }, + { "code" : "RequestGroup", "param" : ["subject", "participant"] }, + { "code" : "ResearchDefinition" }, + { "code" : "ResearchElementDefinition" }, + { "code" : "ResearchStudy" }, + { "code" : "ResearchSubject", "param" : ["individual"] }, + { "code" : "RiskAssessment", "param" : ["subject"] }, + { "code" : "Schedule", "param" : ["actor"] }, + { "code" : "SearchParameter" }, + { "code" : "ServiceRequest", "param" : ["subject", "performer"] }, + { "code" : "Slot" }, + { "code" : "Specimen", "param" : ["subject"] }, + { "code" : "SpecimenDefinition" }, + { "code" : "StructureDefinition" }, + { "code" : "StructureMap" }, + { "code" : "Subscription" }, + { "code" : "SubscriptionStatus" }, + { "code" : "SubscriptionTopic" }, + { "code" : "Substance" }, + { "code" : "SubstanceDefinition" }, + { "code" : "SupplyDelivery", "param" : ["patient"] }, + { "code" : "SupplyRequest", "param" : ["subject"] }, + { "code" : "Task" }, + { "code" : "TerminologyCapabilities" }, + { "code" : "TestReport" }, + { "code" : "TestScript" }, + { "code" : "ValueSet" }, + { "code" : "VerificationResult" }, + { "code" : "VisionPrescription", "param" : ["patient"] } + ] +} diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/DefaultTransferProcessRunnerTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/DefaultTransferProcessRunnerTest.java index 506fbf3ce..8dced3f36 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/DefaultTransferProcessRunnerTest.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/DefaultTransferProcessRunnerTest.java @@ -49,7 +49,9 @@ void runMockTestSuccessfully() { "test", rawConfig, pids -> fromIterable(List.of(PATIENT)), - p -> fromIterable(List.of(new ConsentedPatientBundle(new Bundle(), PATIENT))), + p -> + fromIterable( + List.of(new ConsentedPatientBundle(new Bundle(), PATIENT, PATIENT_ID))), b -> just(new TransportBundle(new Bundle(), "transferId")), b -> just(new Result())); @@ -72,7 +74,9 @@ void runMockTestWithSkippedBundles() { "test", rawConfig, pids -> fromIterable(List.of(PATIENT, patient2)), - p -> fromIterable(List.of(new ConsentedPatientBundle(new Bundle(), PATIENT))), + p -> + fromIterable( + List.of(new ConsentedPatientBundle(new Bundle(), PATIENT, PATIENT_ID))), b -> just(new TransportBundle(new Bundle(), "transferId")), b -> { if (first.getAndSet(false)) { @@ -99,7 +103,9 @@ void errorInCohortSelector() { "test", rawConfig, pids -> Flux.error(new Throwable("Error fetching consented patients")), - p -> fromIterable(List.of(new ConsentedPatientBundle(new Bundle(), PATIENT))), + p -> + fromIterable( + List.of(new ConsentedPatientBundle(new Bundle(), PATIENT, PATIENT_ID))), b -> just(new TransportBundle(new Bundle(), "tIDMapName")), b -> Mono.just(new Result())); @@ -121,7 +127,9 @@ void startMultipleProcessesWithQueueing() { "test", rawConfig, pids -> fromIterable(List.of(PATIENT)), - p -> fromIterable(List.of(new ConsentedPatientBundle(new Bundle(), PATIENT))), + p -> + fromIterable( + List.of(new ConsentedPatientBundle(new Bundle(), PATIENT, PATIENT_ID))), b -> just(new TransportBundle(new Bundle(), "transferId")) .delayElement(Duration.ofMillis(100)), @@ -170,7 +178,9 @@ void ttl() throws InterruptedException { "test", rawConfig, pids -> fromIterable(List.of(PATIENT)), - p -> fromIterable(List.of(new ConsentedPatientBundle(new Bundle(), PATIENT))), + p -> + fromIterable( + List.of(new ConsentedPatientBundle(new Bundle(), PATIENT, PATIENT_ID))), b -> just(new TransportBundle(new Bundle(), "transferId")), b -> just(new Result())); config.setProcessTtl(Duration.ofMillis(100)); diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepConfigTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepConfigTest.java index 6c7cf3ebe..6d42259db 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepConfigTest.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepConfigTest.java @@ -9,7 +9,13 @@ public class DeidentifhirStepConfigTest { @Test void missingDateShiftPreserveDefaultsToNone() { - var config = new DeidentifhirStepConfig(null, null, null, null, null); + var config = new DeidentifhirStepConfig(null, null, null, null, null, null); assertThat(config.dateShiftPreserve()).isEqualTo(DateShiftPreserve.NONE); } + + @Test + void missingEnableCompartmentNamespacingDefaultsToFalse() { + var config = new DeidentifhirStepConfig(null, null, null, null, null, null); + assertThat(config.enableCompartmentNamespacing()).isFalse(); + } } diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepFactoryIT.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepFactoryIT.java index ed1c9609c..ebc2e8e0f 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepFactoryIT.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepFactoryIT.java @@ -5,6 +5,7 @@ import care.smith.fts.api.cda.Deidentificator; import care.smith.fts.cda.impl.DeidentifhirStepConfig.TCAConfig; +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; import care.smith.fts.util.HttpClientConfig; import care.smith.fts.util.WebClientFactory; import care.smith.fts.util.tca.TcaDomains; @@ -20,12 +21,13 @@ class DeidentifhirStepFactoryIT { @Autowired private MeterRegistry meterRegistry; @Autowired private WebClientFactory clientFactory; + @Autowired private PatientCompartmentService patientCompartmentService; private DeidentifhirStepFactory factory; @BeforeEach void setUp() { - factory = new DeidentifhirStepFactory(clientFactory, meterRegistry); + factory = new DeidentifhirStepFactory(clientFactory, meterRegistry, patientCompartmentService); } @Test @@ -45,6 +47,7 @@ void create() { ofDays(14), new File("deidentifhirConfig"), new File("scraperConfig"), + null, null))) .isNotNull(); } diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepIT.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepIT.java index 0d734b752..6865d821c 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepIT.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/DeidentifhirStepIT.java @@ -21,6 +21,7 @@ import care.smith.fts.api.TransportBundle; import care.smith.fts.cda.ClinicalDomainAgent; import care.smith.fts.cda.services.deidentifhir.DeidentifhirUtils; +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; import care.smith.fts.test.connection_scenario.AbstractConnectionScenarioIT; import care.smith.fts.util.WebClientFactory; import care.smith.fts.util.tca.TcaDomains; @@ -51,18 +52,29 @@ class DeidentifhirStepIT extends AbstractConnectionScenarioIT { void setUp( WireMockRuntimeInfo wireMockRuntime, @Autowired WebClientFactory clientFactory, - @Autowired MeterRegistry meterRegistry) + @Autowired MeterRegistry meterRegistry, + @Autowired PatientCompartmentService patientCompartmentService) throws IOException { var scrConf = parseResources(DeidentifhirUtils.class, "IDScraper.profile"); var deiConf = parseResources(DeidentifhirUtils.class, "CDtoTransport.profile"); var domains = new TcaDomains("domain", "domain", "domain"); var client = clientFactory.create(clientConfig(wireMockRuntime)); wireMock = wireMockRuntime.getWireMock(); - step = new DeidentifhirStep(client, domains, ofDays(14), NONE, deiConf, scrConf, meterRegistry); + step = + new DeidentifhirStep( + client, + domains, + ofDays(14), + NONE, + deiConf, + scrConf, + meterRegistry, + patientCompartmentService, + true); var bundle = generateOnePatient("id1", "2024", "identifierSystem", "identifier1"); var consentedPatient = new ConsentedPatient("id1", "system"); - consentedPatientBundle = new ConsentedPatientBundle(bundle, consentedPatient); + consentedPatientBundle = new ConsentedPatientBundle(bundle, consentedPatient, "id1"); } private static MappingBuilder transportMappingRequest() { @@ -99,7 +111,8 @@ void correctRequestSent() { """ { "patientId": "id1", - "resourceIds": [ "id1.identifier.identifierSystem:identifier1", "id1.Patient:id1" ], + "compartmentResourceIds": [ "id1.identifier.identifierSystem:identifier1", "id1.Patient:id1" ], + "nonCompartmentResourceIds": [], "tcaDomains": { "pseudonym": "domain", "salt": "domain", @@ -144,7 +157,8 @@ void deidentifySucceeds() { void emptyIdsYieldEmptyMono() { create( step.deidentify( - new ConsentedPatientBundle(new Bundle(), new ConsentedPatient("id1", "system")))) + new ConsentedPatientBundle( + new Bundle(), new ConsentedPatient("id1", "system"), "id1"))) .expectNextCount(0) .verifyComplete(); } diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/IdatScraperTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/IdatScraperTest.java index 90e822de6..cb9897df1 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/IdatScraperTest.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/IdatScraperTest.java @@ -6,24 +6,50 @@ import care.smith.fts.api.ConsentedPatient; import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.hl7.fhir.r4.model.Bundle; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class IdatScraperTest { - IdatScraper scraper; + private IdatScraper scraper; + private PatientCompartmentService patientCompartmentService; @BeforeEach void setUp() { ConsentedPatient patient = new ConsentedPatient("id1", "identifierSystem1"); var config = parseResources(IdatScraperTest.class, "IDScraper.profile"); - scraper = new IdatScraper(config, patient); + + // Patient resource is in compartment (IS the patient) + Map> compartmentParams = + Map.of( + "Patient", List.of("link"), + "ServiceRequest", List.of("subject", "performer"), + "Organization", List.of()); + patientCompartmentService = new PatientCompartmentService(compartmentParams); + + scraper = new IdatScraper(config, patient, patientCompartmentService, "id1", false); } @Test void gatherIDs() throws IOException { var bundle = generateOnePatient("id1", "2023", "identifierSystem1", "identifier1"); - assertThat(scraper.gatherIDs(bundle)) + var ids = scraper.gatherIDs(bundle); + + assertThat(ids.compartment()) .containsExactlyInAnyOrder( "id1.identifier.identifierSystem1:identifier1", "id1.Patient:id1"); + assertThat(ids.nonCompartment()).isEmpty(); + } + + @Test + void gatherIDs_emptyBundle() { + var bundle = new Bundle(); + bundle.setType(Bundle.BundleType.COLLECTION); + + var ids = scraper.gatherIDs(bundle); + + assertThat(ids.isEmpty()).isTrue(); } } diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentServiceTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentServiceTest.java new file mode 100644 index 000000000..d19e5a10b --- /dev/null +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/PatientCompartmentServiceTest.java @@ -0,0 +1,508 @@ +package care.smith.fts.cda.services.deidentifhir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import org.hl7.fhir.r4.model.Appointment; +import org.hl7.fhir.r4.model.CareTeam; +import org.hl7.fhir.r4.model.Coverage; +import org.hl7.fhir.r4.model.Organization; +import org.hl7.fhir.r4.model.Reference; +import org.hl7.fhir.r4.model.Resource; +import org.hl7.fhir.r4.model.ServiceRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PatientCompartmentServiceTest { + + private static final String PATIENT_ID = "patient123"; + + private PatientCompartmentService service; + + @BeforeEach + void setUp() { + // ServiceRequest has params ["subject", "performer"] according to compartment definition + // Organization has no params (never in compartment) + // Appointment has "actor" which requires nested path: participant.actor + // CareTeam has "participant" which requires nested path: participant.member + // Coverage has "policy-holder" which maps to field "policyHolder" + Map> compartmentParams = + Map.of( + "ServiceRequest", List.of("subject", "performer"), + "Organization", List.of(), + "Observation", List.of("subject", "performer"), + "Appointment", List.of("actor"), + "CareTeam", List.of("participant"), + "Coverage", List.of("policy-holder", "subscriber", "beneficiary")); + service = new PatientCompartmentService(compartmentParams); + } + + @Nested + @DisplayName("ServiceRequest compartment membership") + class ServiceRequestTests { + + @Test + @DisplayName("subject references patient -> IN compartment") + void subjectReferencesPatient_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr1"); + sr.setSubject(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("performer references patient -> IN compartment") + void performerReferencesPatient_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr2"); + sr.setSubject(new Reference("Group/group1")); + sr.addPerformer(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("both subject and performer reference patient -> IN compartment") + void bothReferencePatient_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr3"); + sr.setSubject(new Reference("Patient/" + PATIENT_ID)); + sr.addPerformer(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("neither subject nor performer references patient -> NOT in compartment") + void neitherReferencesPatient_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr4"); + sr.setSubject(new Reference("Group/group1")); + sr.addPerformer(new Reference("Organization/org1")); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName( + "other field (requester) references patient, but not a param -> NOT in compartment") + void otherFieldReferencesPatient_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr5"); + sr.setSubject(new Reference("Group/group1")); + sr.addPerformer(new Reference("Organization/org1")); + sr.setRequester(new Reference("Patient/" + PATIENT_ID)); // Not a compartment param! + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("subject references different patient -> NOT in compartment") + void subjectReferencesDifferentPatient_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr6"); + sr.setSubject(new Reference("Patient/differentPatient")); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("empty ServiceRequest -> NOT in compartment") + void emptyServiceRequest_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr7"); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Organization compartment membership") + class OrganizationTests { + + @Test + @DisplayName("Organization has no params -> never in compartment") + void organizationNeverInCompartment() { + var org = new Organization(); + org.setId("org1"); + + assertThat(service.isInPatientCompartment(org, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Patient compartment membership") + class PatientTests { + + @Test + @DisplayName("Patient resource IS the patient -> IN compartment") + void patientResourceIsPatient_isInCompartment() { + var patient = new org.hl7.fhir.r4.model.Patient(); + patient.setId(PATIENT_ID); + + assertThat(service.isInPatientCompartment(patient, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("Patient resource is different patient -> NOT in compartment") + void patientResourceIsDifferentPatient_notInCompartment() { + var patient = new org.hl7.fhir.r4.model.Patient(); + patient.setId("differentPatient"); + + assertThat(service.isInPatientCompartment(patient, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Multiple performers") + class MultiplePerformersTests { + + @Test + @DisplayName("one of multiple performers references patient -> IN compartment") + void oneOfMultiplePerformersReferencesPatient_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr8"); + sr.setSubject(new Reference("Group/group1")); + sr.addPerformer(new Reference("Practitioner/practitioner1")); + sr.addPerformer(new Reference("Patient/" + PATIENT_ID)); + sr.addPerformer(new Reference("Organization/org1")); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + } + + @Nested + @DisplayName("Nested path resolution - Appointment (participant.actor)") + class AppointmentNestedPathTests { + + @Test + @DisplayName("participant.actor references patient -> IN compartment") + void participantActorReferencesPatient_isInCompartment() { + var appointment = new Appointment(); + appointment.setId("apt1"); + var participant = appointment.addParticipant(); + participant.setActor(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(appointment, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("multiple participants, one references patient -> IN compartment") + void multipleParticipants_oneReferencesPatient_isInCompartment() { + var appointment = new Appointment(); + appointment.setId("apt2"); + appointment.addParticipant().setActor(new Reference("Practitioner/doc1")); + appointment.addParticipant().setActor(new Reference("Patient/" + PATIENT_ID)); + appointment.addParticipant().setActor(new Reference("Location/loc1")); + + assertThat(service.isInPatientCompartment(appointment, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("no participant references patient -> NOT in compartment") + void noParticipantReferencesPatient_notInCompartment() { + var appointment = new Appointment(); + appointment.setId("apt3"); + appointment.addParticipant().setActor(new Reference("Practitioner/doc1")); + appointment.addParticipant().setActor(new Reference("Location/loc1")); + + assertThat(service.isInPatientCompartment(appointment, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("empty appointment -> NOT in compartment") + void emptyAppointment_notInCompartment() { + var appointment = new Appointment(); + appointment.setId("apt4"); + + assertThat(service.isInPatientCompartment(appointment, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Nested path resolution - CareTeam (participant.member)") + class CareTeamNestedPathTests { + + @Test + @DisplayName("participant.member references patient -> IN compartment") + void participantMemberReferencesPatient_isInCompartment() { + var careTeam = new CareTeam(); + careTeam.setId("ct1"); + var participant = careTeam.addParticipant(); + participant.setMember(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(careTeam, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("multiple participants, one member references patient -> IN compartment") + void multipleParticipants_oneMemberReferencesPatient_isInCompartment() { + var careTeam = new CareTeam(); + careTeam.setId("ct2"); + careTeam.addParticipant().setMember(new Reference("Practitioner/doc1")); + careTeam.addParticipant().setMember(new Reference("Patient/" + PATIENT_ID)); + careTeam.addParticipant().setMember(new Reference("Organization/org1")); + + assertThat(service.isInPatientCompartment(careTeam, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("no participant member references patient -> NOT in compartment") + void noParticipantMemberReferencesPatient_notInCompartment() { + var careTeam = new CareTeam(); + careTeam.setId("ct3"); + careTeam.addParticipant().setMember(new Reference("Practitioner/doc1")); + + assertThat(service.isInPatientCompartment(careTeam, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Simple mapping - Coverage (policy-holder -> policyHolder)") + class CoverageSimpleMappingTests { + + @Test + @DisplayName("policyHolder references patient -> IN compartment") + void policyHolderReferencesPatient_isInCompartment() { + var coverage = new Coverage(); + coverage.setId("cov1"); + coverage.setPolicyHolder(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(coverage, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("subscriber references patient -> IN compartment") + void subscriberReferencesPatient_isInCompartment() { + var coverage = new Coverage(); + coverage.setId("cov2"); + coverage.setSubscriber(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(coverage, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("beneficiary references patient -> IN compartment") + void beneficiaryReferencesPatient_isInCompartment() { + var coverage = new Coverage(); + coverage.setId("cov3"); + coverage.setBeneficiary(new Reference("Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(coverage, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("no fields reference patient -> NOT in compartment") + void noFieldsReferencePatient_notInCompartment() { + var coverage = new Coverage(); + coverage.setId("cov4"); + coverage.setPolicyHolder(new Reference("RelatedPerson/rp1")); + coverage.setSubscriber(new Reference("RelatedPerson/rp2")); + coverage.setBeneficiary(new Reference("Patient/differentPatient")); + + assertThat(service.isInPatientCompartment(coverage, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Edge cases for nested path resolution") + class NestedPathEdgeCasesTests { + + @Test + @DisplayName("resource in NESTED_PATHS but param not in inner map -> falls back to top-level") + void resourceInNestedPathsButParamNotInMap_fallsBackToTopLevel() { + // Appointment is in NESTED_PATHS with "actor" param, but we test with "patient" param + // which is NOT in Appointment's nested paths map, triggering line 171 (paths == null) + var compartmentParams = Map.of("Appointment", List.of("patient")); + var checkerWithDifferentParam = new PatientCompartmentService(compartmentParams); + + var appointment = new Appointment(); + appointment.setId("apt-edge1"); + // "patient" param would fall back to top-level lookup, which won't find anything + // because Appointment doesn't have a top-level "patient" field + + assertThat(checkerWithDifferentParam.isInPatientCompartment(appointment, PATIENT_ID)) + .isFalse(); + } + + @Test + @DisplayName("participant exists but actor is null -> empty refs from traversePath") + void participantExistsButActorNull_emptyRefs() { + // Tests line 207: prop.getValues().isEmpty() at the final path segment + var appointment = new Appointment(); + appointment.setId("apt-edge2"); + var participant = appointment.addParticipant(); + // participant exists but actor is not set (null) + + assertThat(service.isInPatientCompartment(appointment, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("deeply nested path with missing intermediate property") + void deeplyNestedPathWithMissingIntermediate_emptyRefs() { + // RequestGroup has path "action.participant.actor" - tests traversal through empty lists + var compartmentParams = Map.of("RequestGroup", List.of("participant")); + var checkerForRequestGroup = new PatientCompartmentService(compartmentParams); + + var requestGroup = new org.hl7.fhir.r4.model.RequestGroup(); + requestGroup.setId("rg1"); + // action list is empty, so traversePath returns empty at first segment + + assertThat(checkerForRequestGroup.isInPatientCompartment(requestGroup, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("exception during nested path traversal is handled gracefully") + void exceptionDuringNestedPathTraversal_handledGracefully() { + // Configure a checker that uses Appointment (which has nested paths) + var compartmentParams = Map.of("Appointment", List.of("actor")); + var checkerWithMock = new PatientCompartmentService(compartmentParams); + + // Mock resource that throws when traversing nested path + Resource mockResource = mock(Resource.class); + when(mockResource.fhirType()).thenReturn("Appointment"); + when(mockResource.getIdPart()).thenReturn("mock-apt"); + when(mockResource.getNamedProperty("participant")) + .thenThrow(new RuntimeException("Simulated traversal error")); + + // Should handle exception gracefully and return false + assertThat(checkerWithMock.isInPatientCompartment(mockResource, PATIENT_ID)).isFalse(); + } + } + + @Nested + @DisplayName("Edge cases for reference handling") + class ReferenceEdgeCasesTests { + + @Test + @DisplayName("null reference -> NOT in compartment") + void nullReference_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr9"); + sr.setSubject(null); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("empty reference -> NOT in compartment") + void emptyReference_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr10"); + sr.setSubject(new Reference()); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("reference with null value -> NOT in compartment") + void referenceWithNullValue_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr11"); + var ref = new Reference(); + ref.setDisplay("Some display"); // Has display but no reference value + sr.setSubject(ref); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("reference to non-Patient resource type -> NOT in compartment") + void referenceToNonPatient_notInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr12"); + sr.setSubject(new Reference("RelatedPerson/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("invalid param name does not cause exception") + void invalidParamName_handledGracefully() { + var compartmentParams = Map.of("ServiceRequest", List.of("nonExistentParam")); + var checkerWithInvalidParam = new PatientCompartmentService(compartmentParams); + + var sr = new ServiceRequest(); + sr.setId("sr13"); + sr.setSubject(new Reference("Patient/" + PATIENT_ID)); + + // Should not throw, just return false since "nonExistentParam" doesn't exist + assertThat(checkerWithInvalidParam.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("exception during property lookup is handled gracefully") + void exceptionDuringPropertyLookup_handledGracefully() { + var compartmentParams = Map.of("TestResource", List.of("subject")); + var checkerWithMock = new PatientCompartmentService(compartmentParams); + + // Create a mock resource that throws when getNamedProperty is called + Resource mockResource = mock(Resource.class); + when(mockResource.fhirType()).thenReturn("TestResource"); + when(mockResource.getIdPart()).thenReturn("test1"); + when(mockResource.getNamedProperty(anyString())) + .thenThrow(new RuntimeException("Test exception")); + + // Should handle the exception gracefully and return false + assertThat(checkerWithMock.isInPatientCompartment(mockResource, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("Patient resource type but null ID -> NOT in compartment") + void patientResourceTypeWithNullId_notInCompartment() { + // Tests the && short-circuit at line 38 where resourceType is Patient but getIdPart is null + var patient = new org.hl7.fhir.r4.model.Patient(); + // Don't set ID, so getIdPart() returns null + + assertThat(service.isInPatientCompartment(patient, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("reference with just ID (no type prefix) -> NOT in compartment") + void referenceWithJustId_notInCompartment() { + // Tests extractIdFromReference with non-Patient/ prefix + var sr = new ServiceRequest(); + sr.setId("sr14"); + sr.setSubject(new Reference(PATIENT_ID)); // Just ID, no "Patient/" prefix + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isFalse(); + } + + @Test + @DisplayName("full URL reference ending with Patient/ID -> IN compartment") + void fullUrlReference_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr15"); + sr.setSubject(new Reference("http://example.com/fhir/Patient/" + PATIENT_ID)); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("reference with trailing path after ID -> IN compartment") + void referenceWithTrailingPath_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr16"); + sr.setSubject(new Reference("Patient/" + PATIENT_ID + "/_history/1")); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + + @Test + @DisplayName("reference with query params after ID -> IN compartment") + void referenceWithQueryParams_isInCompartment() { + var sr = new ServiceRequest(); + sr.setId("sr17"); + sr.setSubject(new Reference("Patient/" + PATIENT_ID + "?_format=json")); + + assertThat(service.isInPatientCompartment(sr, PATIENT_ID)).isTrue(); + } + } +} diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorageTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorageTest.java index b4d278fc8..4d2518c5d 100644 --- a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorageTest.java +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/ScrapingStorageTest.java @@ -3,27 +3,75 @@ import static org.assertj.core.api.Assertions.assertThat; import care.smith.fts.util.deidentifhir.NamespacingReplacementProvider; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class ScrapingStorageTest { - ScrapingStorage scrapingStorage; + + private static final String PATIENT_ID = "patient123"; + + private ScrapingStorage storage; @BeforeEach void setUp() { - scrapingStorage = new ScrapingStorage(NamespacingReplacementProvider.withNamespacing("test")); + var keyCreator = NamespacingReplacementProvider.withNamespacing(PATIENT_ID); + storage = new ScrapingStorage(keyCreator); + } + + @Test + void getIDReplacement_inCompartment_hasPatientPrefix() { + storage.setCompartmentMembership(Map.of("Observation:obs1", true)); + + var result = storage.getIDReplacement("Observation", "obs1"); + + assertThat(result).isEqualTo("obs1"); + assertThat(storage.getCompartmentIds()).contains("patient123.Observation:obs1"); + assertThat(storage.getNonCompartmentIds()).isEmpty(); } @Test - void getIDReplacement() { - assertThat(scrapingStorage.getIDReplacement("Patient", "patientId")).isEqualTo("patientId"); - assertThat(scrapingStorage.getGatheredIdats()).containsExactly("test.Patient:patientId"); + void getIDReplacement_notInCompartment_noPatientPrefix() { + storage.setCompartmentMembership(Map.of("Organization:org1", false)); + + var result = storage.getIDReplacement("Organization", "org1"); + + assertThat(result).isEqualTo("org1"); + assertThat(storage.getNonCompartmentIds()).contains("Organization:org1"); + assertThat(storage.getCompartmentIds()).isEmpty(); } @Test - void getValueReplacement() { - assertThat(scrapingStorage.getValueReplacement("Patient", "patientId")).isEqualTo("patientId"); - assertThat(scrapingStorage.getGatheredIdats()) - .containsExactly("test.identifier.Patient:patientId"); + void getIDReplacement_unknownResource_defaultsToCompartment() { + // If not in membership map, defaults to true (in compartment) + var result = storage.getIDReplacement("Unknown", "unknown1"); + + assertThat(result).isEqualTo("unknown1"); + assertThat(storage.getCompartmentIds()).contains("patient123.Unknown:unknown1"); + } + + @Test + void getValueReplacement_alwaysHasPatientPrefix() { + var result = storage.getValueReplacement("urn:system", "value123"); + + assertThat(result).isEqualTo("value123"); + assertThat(storage.getCompartmentIds()).contains("patient123.identifier.urn:system:value123"); + } + + @Test + void ids_accumulatesAcrossMultipleCalls() { + storage.setCompartmentMembership( + Map.of( + "Observation:obs1", true, + "Organization:org1", false)); + + storage.getIDReplacement("Observation", "obs1"); + storage.getIDReplacement("Organization", "org1"); + storage.getValueReplacement("system", "value1"); + + assertThat(storage.getCompartmentIds()) + .containsExactlyInAnyOrder( + "patient123.Observation:obs1", "patient123.identifier.system:value1"); + assertThat(storage.getNonCompartmentIds()).containsExactly("Organization:org1"); } } diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfigurationTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfigurationTest.java new file mode 100644 index 000000000..5bd577df6 --- /dev/null +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/services/deidentifhir/configuration/PatientCompartmentServiceConfigurationTest.java @@ -0,0 +1,97 @@ +package care.smith.fts.cda.services.deidentifhir.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService; +import care.smith.fts.cda.services.deidentifhir.PatientCompartmentService.ResourceEntry; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PatientCompartmentServiceConfigurationTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void loadsCompartmentDefinitionFromClasspath() { + var config = new PatientCompartmentServiceConfiguration(); + Map> params = config.patientCompartmentParams(objectMapper); + + // Verify ServiceRequest has correct params from compartment definition + assertThat(params.getOrDefault("ServiceRequest", List.of())) + .containsExactlyInAnyOrder("subject", "performer"); + + // Verify Observation has correct params + assertThat(params.getOrDefault("Observation", List.of())) + .containsExactlyInAnyOrder("subject", "performer"); + + // Verify Organization has no params (not in compartment) + assertThat(params.getOrDefault("Organization", List.of())).isEmpty(); + + // Verify Medication has no params (not in compartment) + assertThat(params.getOrDefault("Medication", List.of())).isEmpty(); + + // Verify Patient has link param + assertThat(params.getOrDefault("Patient", List.of())).containsExactly("link"); + } + + @Nested + class ResourceEntryTests { + + @Test + void paramsOrEmpty_withNullParam_returnsEmptyList() { + var entry = new ResourceEntry("TestResource", null); + assertThat(entry.paramsOrEmpty()).isEmpty(); + } + + @Test + void paramsOrEmpty_withParams_returnsList() { + var entry = new ResourceEntry("TestResource", List.of("subject", "performer")); + assertThat(entry.paramsOrEmpty()).containsExactly("subject", "performer"); + } + + @Test + void paramsOrEmpty_withEmptyParams_returnsEmptyList() { + var entry = new ResourceEntry("TestResource", List.of()); + assertThat(entry.paramsOrEmpty()).isEmpty(); + } + } + + @Nested + class ErrorHandlingTests { + + @Test + void missingResourceFile_throwsIllegalStateException() { + assertThatThrownBy( + () -> + PatientCompartmentService.loadCompartmentDefinition( + objectMapper, "fhir/non-existent-file.json")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to load patient compartment definition"); + } + + @Test + void nullResourceArray_throwsIllegalStateException() { + assertThatThrownBy( + () -> + PatientCompartmentService.loadCompartmentDefinition( + objectMapper, "fhir/compartmentdefinition-null-resource.json")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Invalid compartment definition: missing resource array"); + } + + @Test + void duplicateResourceCodes_firstOneWins() { + var params = + PatientCompartmentService.loadCompartmentDefinition( + objectMapper, "fhir/compartmentdefinition-with-duplicates.json"); + + // First entry for TestResource has ["subject"], second has ["performer"] + // The merge function (a, b) -> a means first one wins + assertThat(params.getOrDefault("TestResource", List.of())).containsExactly("subject"); + } + } +} diff --git a/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-null-resource.json b/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-null-resource.json new file mode 100644 index 000000000..c13eb25c9 --- /dev/null +++ b/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-null-resource.json @@ -0,0 +1,5 @@ +{ + "resourceType": "CompartmentDefinition", + "id": "patient", + "resource": null +} diff --git a/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-with-duplicates.json b/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-with-duplicates.json new file mode 100644 index 000000000..973edc1a1 --- /dev/null +++ b/clinical-domain-agent/src/test/resources/fhir/compartmentdefinition-with-duplicates.json @@ -0,0 +1,9 @@ +{ + "resourceType": "CompartmentDefinition", + "id": "patient", + "resource": [ + { "code": "TestResource", "param": ["subject"] }, + { "code": "TestResource", "param": ["performer"] }, + { "code": "AnotherResource", "param": ["patient"] } + ] +} diff --git a/trust-center-agent/src/e2e/java/care/smith/fts/tca/SecureMappingE2E.java b/trust-center-agent/src/e2e/java/care/smith/fts/tca/SecureMappingE2E.java index 1bb451b71..b81f00ca0 100644 --- a/trust-center-agent/src/e2e/java/care/smith/fts/tca/SecureMappingE2E.java +++ b/trust-center-agent/src/e2e/java/care/smith/fts/tca/SecureMappingE2E.java @@ -46,6 +46,7 @@ void testSecureMappingRetrieval() { Set.of( "patient-id-1.Patient:patient-id-1", "patient-id-1.identifier.http://fts.smith.care:patient-identifier-1"), + Set.of(), tcaDomains, Duration.ofDays(14), DateShiftPreserve.NONE); diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/FhirMappingProvider.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/FhirMappingProvider.java index f4b446a1b..e2b969ab8 100644 --- a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/FhirMappingProvider.java +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/FhirMappingProvider.java @@ -18,9 +18,10 @@ import io.micrometer.core.instrument.MeterRegistry; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.HashSet; import java.util.Map; import java.util.Map.Entry; -import java.util.function.Function; +import java.util.Set; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RMapReactive; import org.redisson.api.RedissonClient; @@ -35,6 +36,9 @@ public class FhirMappingProvider implements MappingProvider { record PseudonymData(String patientIdPseudonym, String salt, String dateShiftSeed) {} + private record FetchedData( + PseudonymData pseudonymData, Map nonCompartmentPseudonyms) {} + private final GpasClient gpasClient; private final TransportMappingConfiguration configuration; private final RedissonClient redisClient; @@ -58,22 +62,63 @@ public FhirMappingProvider( * For all provided IDs fetch the id:pid pairs from gPAS. Then create TransportIDs (id:tid pairs). * Store tid:pid in the key-value-store. * - * @param r the transport mapping request - * @return Map + *

IDs are received in two categories: + * + *

    + *
  • Patient-compartment IDs: pseudonymized using patient-derived salt (SHA256 hash) + *
  • Non-compartment IDs: pseudonymized directly via gPAS + *
*/ @Override public Mono generateTransportMapping(TransportMappingRequest r) { - log.trace("retrieveTransportIds patientId={}, resourceIds={}", r.patientId(), r.resourceIds()); + log.trace( + "retrieveTransportIds patientId={}, compartmentIds={}, nonCompartmentIds={}", + r.patientId(), + r.compartmentResourceIds().size(), + r.nonCompartmentResourceIds().size()); var transferId = randomStringGenerator.generate(); + + var compartmentIds = r.compartmentResourceIds(); + var nonCompartmentIds = r.nonCompartmentResourceIds(); + + // Combine both sets for transport mapping + var allIds = new HashSet<>(compartmentIds); + allIds.addAll(nonCompartmentIds); + var transportMapping = - r.resourceIds().stream().collect(toMap(id -> id, id -> randomStringGenerator.generate())); + allIds.stream().collect(toMap(id -> id, id -> randomStringGenerator.generate())); + var sMap = redisClient.reactive().getMapCache(transferId); return sMap.expire(configuration.getTtl()) .then(fetchPseudonymAndSalts(r.patientId(), r.tcaDomains(), r.maxDateShift())) - .flatMap(saveSecureMapping(r, transportMapping, sMap)) + .flatMap( + pseudonymData -> + fetchNonCompartmentPseudonyms(nonCompartmentIds, r.tcaDomains().pseudonym()) + .map( + nonCompartmentPseudonyms -> + new FetchedData(pseudonymData, nonCompartmentPseudonyms))) + .flatMap( + data -> + saveSecureMappingWithCompartment( + r, + transportMapping, + compartmentIds, + nonCompartmentIds, + data.nonCompartmentPseudonyms(), + sMap, + data.pseudonymData())) .map(cdShift -> new TransportMappingResponse(transferId, transportMapping, cdShift)); } + /** Fetches pseudonyms from gPAS for non-compartment resource IDs. */ + private Mono> fetchNonCompartmentPseudonyms( + Set nonCompartmentIds, String domain) { + if (nonCompartmentIds.isEmpty()) { + return Mono.just(Map.of()); + } + return gpasClient.fetchOrCreatePseudonyms(domain, nonCompartmentIds); + } + private Mono fetchPseudonymAndSalts( String patientId, TcaDomains domains, Duration maxDateShift) { var saltKey = "Salt_" + patientId; @@ -91,44 +136,83 @@ private Mono fetchPseudonymAndSalts( .map(t -> new PseudonymData(t.getT1(), t.getT2(), t.getT3())); } - /** Saves the research mapping in redis for later use by the rda. */ - static Function> saveSecureMapping( + /** + * Saves the research mapping in redis for later use by the rda, with compartment awareness. + * + *

Patient-compartment IDs are hashed with salt, non-compartment IDs use gPAS pseudonyms. + */ + private Mono saveSecureMappingWithCompartment( + TransportMappingRequest r, + Map transportMapping, + Set compartmentIds, + Set nonCompartmentIds, + Map nonCompartmentPseudonyms, + RMapReactive rMap, + PseudonymData data) { + var dateShifts = generate(data.dateShiftSeed(), r.maxDateShift(), r.dateShiftPreserve()); + + var resolveMap = + buildResolveMap( + r, + transportMapping, + compartmentIds, + nonCompartmentIds, + nonCompartmentPseudonyms, + data.salt(), + data.patientIdPseudonym(), + dateShifts); + + return rMap.putAll(resolveMap).thenReturn(dateShifts.cdDateShift()); + } + + private ImmutableMap buildResolveMap( TransportMappingRequest r, Map transportMapping, - RMapReactive rMap) { - return data -> { - var dateShifts = generate(data.dateShiftSeed(), r.maxDateShift(), r.dateShiftPreserve()); - var resolveMap = - ImmutableMap.builder() - .putAll(generateSecureMapping(data.salt(), transportMapping)) - .putAll( - patientIdPseudonyms( - r.patientId(), - r.patientIdentifierSystem(), - data.patientIdPseudonym(), - transportMapping)) - .put("dateShiftMillis", valueOf(dateShifts.rdDateShift().toMillis())) - .buildKeepingLast(); - return rMap.putAll(resolveMap).thenReturn(dateShifts.cdDateShift()); - }; + Set compartmentIds, + Set nonCompartmentIds, + Map nonCompartmentPseudonyms, + String salt, + String patientIdPseudonym, + DateShiftUtil.DateShifts dateShifts) { + var compartmentTransportMapping = filterTransportMapping(transportMapping, compartmentIds); + var nonCompartmentTransportMapping = + filterTransportMapping(transportMapping, nonCompartmentIds); + + return ImmutableMap.builder() + .putAll(generateSecureMapping(salt, compartmentTransportMapping)) + .putAll( + generateNonCompartmentMapping(nonCompartmentTransportMapping, nonCompartmentPseudonyms)) + .putAll( + patientIdPseudonyms( + r.patientId(), r.patientIdentifierSystem(), patientIdPseudonym, transportMapping)) + .put("dateShiftMillis", valueOf(dateShifts.rdDateShift().toMillis())) + .buildKeepingLast(); + } + + private static Map filterTransportMapping( + Map transportMapping, Set ids) { + return transportMapping.entrySet().stream() + .filter(e -> ids.contains(e.getKey())) + .collect(toMap(Entry::getKey, Entry::getValue)); + } + + static Map generateNonCompartmentMapping( + Map transportMapping, Map gpasPseudonyms) { + return transportMapping.entrySet().stream() + .filter(e -> gpasPseudonyms.containsKey(e.getKey())) + .collect(toMap(Entry::getValue, e -> gpasPseudonyms.get(e.getKey()))); } - /** generate ids for all entries in the transport mapping */ static Map generateSecureMapping( String transportSalt, Map transportMapping) { return transportMapping.entrySet().stream() .collect(toMap(Entry::getValue, entry -> transportHash(transportSalt, entry.getKey()))); } - /** hash a transport id using the salt */ private static String transportHash(String transportSalt, String id) { return hashFn.hashString(transportSalt + id, StandardCharsets.UTF_8).toString(); } - /** - * With this function we make sure that the patient's ID in the RDA is the de-identified ID stored - * in gPAS. This ensures that we can re-identify patients. - */ static Map patientIdPseudonyms( String patientId, String patientIdentifierSystem, @@ -136,7 +220,6 @@ static Map patientIdPseudonyms( Map transportMapping) { var x = NamespacingReplacementProvider.withNamespacing(patientId); var name = x.getKeyForSystemAndValue(patientIdentifierSystem, patientId); - return transportMapping.entrySet().stream() .filter(entry -> entry.getKey().equals(name)) .collect(toMap(Entry::getValue, id -> patientIdPseudonym)); diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/FhirMappingProviderTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/FhirMappingProviderTest.java index 6c60498fa..8d7bcf788 100644 --- a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/FhirMappingProviderTest.java +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/FhirMappingProviderTest.java @@ -16,7 +16,6 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.OK; @@ -39,7 +38,6 @@ import java.time.Duration; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Random; import java.util.Set; import java.util.stream.Collectors; @@ -51,11 +49,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.redisson.api.RMapCacheReactive; -import org.redisson.api.RMapReactive; import org.redisson.api.RedissonClient; import org.redisson.api.RedissonReactiveClient; import org.redisson.client.RedisTimeoutException; @@ -79,6 +75,7 @@ class FhirMappingProviderTest { "id1", "patientIdentifierSystem", Set.of("id1"), + Set.of(), DEFAULT_DOMAINS, Duration.ofDays(14), DateShiftPreserve.NONE); @@ -143,13 +140,15 @@ void generateTransportMapping() throws IOException { given(mapCache.expire(Duration.ofMinutes(10))).willReturn(Mono.just(false)); given(mapCache.putAll(anyMap())).willReturn(Mono.empty()); - var ids = Set.of("Patient.id1", "id1.identifier.patientIdentifierSystem:id1"); + var ids = + Set.of("id1.Patient:patient-resource-id", "id1.identifier.patientIdentifierSystem:id1"); var mapName = "wSUYQUR3Y"; var request = new TransportMappingRequest( "id1", "patientIdentifierSystem", ids, + Set.of(), DEFAULT_DOMAINS, Duration.ofDays(14), DateShiftPreserve.NONE); @@ -277,530 +276,6 @@ void fetchSecureMappingWrongDateShiftValue() { .verify(); } - @Nested - class SaveSecureMappingTests { - - private static final String PATIENT_ID = "patient-id"; - private static final String PATIENT_ID_PSEUDONYM = "pseudo-patient-id"; - private static final String SALT = "testSalt"; - private static final String DATE_SHIFT_SEED = "dateShiftSeed"; - private static final Duration MAX_DATE_SHIFT = Duration.ofDays(365); - - private Map transportMapping; - - @BeforeEach - void setUp() { - transportMapping = - Map.of( - "patient-id.identifier.patientIdentifierSystem:patient-id", - "tpid", - "id1", - "tid1", - "id2", - "tid2"); - } - - @Test - void saveSecureMappingCreatesCorrectMapping() { - var transportMapping = - Map.of( - "id1", - "tid1", - "id2", - "tid2", - "patient-id.identifier.patientIdentifierSystem:patient-id", - "tid3"); - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - given(mockRMap.putAll(mapCaptor.capture())).willReturn(Mono.empty()); - - var saveFunction = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - @SuppressWarnings("unchecked") - Mono result = saveFunction.apply(tuple); - - create(result).expectNextMatches(Objects::nonNull).verifyComplete(); - - Map savedMap = mapCaptor.getValue(); - assertThat(savedMap).containsKey("dateShiftMillis"); - assertThat(savedMap.get("dateShiftMillis")).matches("^-?\\d+$"); - - assertThat(savedMap).containsKey("tid1"); - assertThat(savedMap).containsKey("tid2"); - assertThat(savedMap).containsKey("tid3"); - - assertThat(savedMap.values()).contains(PATIENT_ID_PSEUDONYM); - } - - @Test - void shouldSaveSecureMappingWithAllRequiredData() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - create(function.apply(tuple)) - .assertNext( - duration -> { - assertThat(duration).isNotNull(); - assertThat(duration.toMillis()).isGreaterThanOrEqualTo(-MAX_DATE_SHIFT.toMillis()); - assertThat(duration.toMillis()).isLessThanOrEqualTo(MAX_DATE_SHIFT.toMillis()); - }) - .verifyComplete(); - } - - @Test - void shouldIncludeSecureTransportMappings() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - ArgumentCaptor> actualMapCaptor = ArgumentCaptor.forClass(Map.class); - org.mockito.Mockito.verify(mockRMap).putAll(actualMapCaptor.capture()); - Map savedMap = actualMapCaptor.getValue(); - - // Should contain secure mappings for all transport values - assertThat(savedMap).containsKey("tpid"); - assertThat(savedMap).containsKey("tid1"); - assertThat(savedMap).containsKey("tid2"); - - // Values should be hashed (different from original transport keys) - assertThat(savedMap.get("tpid")).isNotEqualTo(PATIENT_ID); - assertThat(savedMap.get("tid1")).isNotEqualTo("id1"); - assertThat(savedMap.get("tid2")).isNotEqualTo("id2"); - } - - @Test - void saveSecureMappingHandlesDifferentDateShiftPreserveOptions() { - var transportMapping = Map.of("id1", "tid1"); - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - - for (DateShiftPreserve preserve : DateShiftPreserve.values()) { - var saveFunction = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - preserve), - transportMapping, - mockRMap); - - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - @SuppressWarnings("unchecked") - Mono result = saveFunction.apply(tuple); - - create(result).expectNextMatches(Objects::nonNull).verifyComplete(); - } - } - - @Test - void saveSecureMappingHandlesEmptyTransportMapping() { - var transportMapping = Map.of(); - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - given(mockRMap.putAll(mapCaptor.capture())).willReturn(Mono.empty()); - - var saveFunction = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - @SuppressWarnings("unchecked") - Mono result = saveFunction.apply(tuple); - - create(result).expectNextMatches(Objects::nonNull).verifyComplete(); - - Map savedMap = mapCaptor.getValue(); - assertThat(savedMap).containsKey("dateShiftMillis"); - assertThat(savedMap).hasSize(1); // Only dateShiftMillis should be present - } - - @Test - void saveSecureMappingHandlesRedisFailure() { - var transportMapping = Map.of("id1", "tid1"); - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.error(new RuntimeException("Redis error"))); - - var saveFunction = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - @SuppressWarnings("unchecked") - Mono result = saveFunction.apply(tuple); - - create(result).expectError(RuntimeException.class).verify(); - } - - @Test - void shouldProduceConsistentHashForSameSaltAndTransportId() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - // First call - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Capture first invocation - org.mockito.Mockito.verify(mockRMap).putAll(mapCaptor.capture()); - Map firstCall = mapCaptor.getValue(); - - // Reset mock to prepare for the second invocation - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - - // Second call - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Capture second invocation - org.mockito.Mockito.verify(mockRMap, org.mockito.Mockito.times(2)) - .putAll(mapCaptor.capture()); - Map secondCall = mapCaptor.getValue(); - - // Hashed values should be identical - assertThat(firstCall.get("tpid")).isEqualTo(secondCall.get("tpid")); - assertThat(firstCall.get("tid1")).isEqualTo(secondCall.get("tid1")); - assertThat(firstCall.get("tid2")).isEqualTo(secondCall.get("tid2")); - } - - @Test - void shouldProduceDifferentHashesForDifferentSalts() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple1 = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, "salt1", DATE_SHIFT_SEED); - var tuple2 = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, "salt2", DATE_SHIFT_SEED); - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - - var function1 = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - // First call with salt1 - create(function1.apply(tuple1)).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Capture first invocation - org.mockito.Mockito.verify(mockRMap).putAll(mapCaptor.capture()); - Map firstCall = mapCaptor.getValue(); - - // Reset mock to prepare for the second invocation - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - - var function2 = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - // Second call with salt2 - create(function2.apply(tuple2)).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Capture second invocation - org.mockito.Mockito.verify(mockRMap, org.mockito.Mockito.times(2)) - .putAll(mapCaptor.capture()); - Map secondCall = mapCaptor.getValue(); - - // Hashed values should be different - assertThat(firstCall.get("tpid")).isEqualTo(secondCall.get("tpid")); // sPID stays the same - assertThat(firstCall.get("tid1")).isNotEqualTo(secondCall.get("tid1")); - assertThat(firstCall.get("tid2")).isNotEqualTo(secondCall.get("tid2")); - } - - @Test - void shouldHandleSpecialCharactersInTransportIds() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - Map specialCharMapping = - Map.of( - "transport@#$%_patient123", "value1", - "transport-with-dashes_patient123", "value2", - "transport.with.dots_patient123", "value3"); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - specialCharMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - org.mockito.Mockito.verify(mockRMap).putAll(anyMap()); - } - - @Test - void shouldHandleNullTransportMappingGracefully() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - org.junit.jupiter.api.Assertions.assertThrows( - NullPointerException.class, - () -> { - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - null, - mockRMap); - function.apply(tuple).block(); - }); - } - - @Test - void shouldHandleVeryLargeTransportMappings() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - - // Create large mapping - var largeMapping = new java.util.HashMap(); - for (int i = 0; i < 10000; i++) { - largeMapping.put("transport" + i + "_patient", "value" + i); - } - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - largeMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - } - - @Test - void shouldHandleEmptyStringsInParameters() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = new FhirMappingProvider.PseudonymData("", "", ""); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - } - - @Test - void shouldProduceSha256LengthHashes() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - org.mockito.Mockito.verify(mockRMap).putAll(mapCaptor.capture()); - Map savedMap = mapCaptor.getValue(); - - // SHA-256 hashes should be 64 characters long (hex representation) - savedMap.entrySet().stream() - .filter(entry -> !entry.getKey().equals("dateShiftMillis")) - .filter( - entry -> - !Objects.equals( - entry.getValue(), PATIENT_ID_PSEUDONYM)) // Skip patient ID mappings - .forEach( - entry -> { - assertThat(entry.getValue()).hasSize(64).matches("^[a-f0-9]{64}$"); - }); - } - - @Test - void shouldNotLeakOriginalTransportIdsInHashes() { - @SuppressWarnings("unchecked") - var mockRMap = (RMapReactive) mock(RMapReactive.class); - given(mockRMap.putAll(anyMap())).willReturn(Mono.empty()); - var tuple = - new FhirMappingProvider.PseudonymData(PATIENT_ID_PSEUDONYM, SALT, DATE_SHIFT_SEED); - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - - var function = - FhirMappingProvider.saveSecureMapping( - new TransportMappingRequest( - "patient-id", - "patientIdentifierSystem", - Set.of(), - new TcaDomains("", "", ""), - MAX_DATE_SHIFT, - DateShiftPreserve.NONE), - transportMapping, - mockRMap); - - create(function.apply(tuple)).expectNextMatches(Objects::nonNull).verifyComplete(); - - org.mockito.Mockito.verify(mockRMap).putAll(mapCaptor.capture()); - Map savedMap = mapCaptor.getValue(); - - // Hashed values should not contain any part of original transport IDs - transportMapping - .keySet() - .forEach( - originalId -> { - savedMap - .values() - .forEach( - hashedValue -> { - if (!hashedValue.equals(PATIENT_ID_PSEUDONYM) - && !hashedValue.matches("^-?\\d+$")) { - assertThat(hashedValue).doesNotContain(originalId); - } - }); - }); - } - } - @Nested class GenerateSecureMappingTests { @@ -1009,6 +484,243 @@ void patientIdPseudonymsHandlesSpecialCharacters() { } } + @Nested + class NonCompartmentMappingTests { + + @Test + void generateNonCompartmentMappingCreatesCorrectMapping() { + var transportMapping = Map.of("org-id", "tid1", "loc-id", "tid2", "med-id", "tid3"); + var gpasPseudonyms = + Map.of("org-id", "org-pseudo", "loc-id", "loc-pseudo", "med-id", "med-pseudo"); + + var result = + FhirMappingProvider.generateNonCompartmentMapping(transportMapping, gpasPseudonyms); + + assertThat(result).hasSize(3); + assertThat(result).containsEntry("tid1", "org-pseudo"); + assertThat(result).containsEntry("tid2", "loc-pseudo"); + assertThat(result).containsEntry("tid3", "med-pseudo"); + } + + @Test + void generateNonCompartmentMappingHandlesEmptyMaps() { + var result = FhirMappingProvider.generateNonCompartmentMapping(Map.of(), Map.of()); + + assertThat(result).isEmpty(); + } + + @Test + void generateNonCompartmentMappingFiltersUnmatchedIds() { + var transportMapping = Map.of("id1", "tid1", "id2", "tid2"); + var gpasPseudonyms = Map.of("id1", "pseudo1"); // id2 not in gpasPseudonyms + + var result = + FhirMappingProvider.generateNonCompartmentMapping(transportMapping, gpasPseudonyms); + + assertThat(result).hasSize(1); + assertThat(result).containsEntry("tid1", "pseudo1"); + assertThat(result).doesNotContainKey("tid2"); + } + } + + @Nested + class GpasClientBatchTests { + + @Test + void fetchOrCreatePseudonymsWithEmptySet() { + var gpasConfig = new GpasDeIdentificationConfiguration(); + var gpasClient = + new GpasClient( + httpClientBuilder.baseUrl("http://localhost").build(), meterRegistry, gpasConfig); + + create(gpasClient.fetchOrCreatePseudonyms("domain", Set.of())) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsBatch(WireMockRuntimeInfo wireMockRuntime) { + var address = wireMockRuntime.getHttpBaseUrl(); + var wireMock = wireMockRuntime.getWireMock(); + + // Multi-mapping response for batch call + var batchResponse = + """ + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "pseudonym", + "part": [ + {"name": "original", "valueIdentifier": {"value": "org1"}}, + {"name": "target", "valueIdentifier": {"value": "domain"}}, + {"name": "pseudonym", "valueIdentifier": {"value": "org-pseudo"}} + ] + }, + { + "name": "pseudonym", + "part": [ + {"name": "original", "valueIdentifier": {"value": "loc1"}}, + {"name": "target", "valueIdentifier": {"value": "domain"}}, + {"name": "pseudonym", "valueIdentifier": {"value": "loc-pseudo"}} + ] + } + ] + } + """; + + wireMock.register( + post(urlEqualTo("/$pseudonymizeAllowCreate")) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn(fhirResponse(batchResponse))); + + var gpasConfig = new GpasDeIdentificationConfiguration(); + var gpasClient = + new GpasClient(httpClientBuilder.baseUrl(address).build(), meterRegistry, gpasConfig); + + create(gpasClient.fetchOrCreatePseudonyms("domain", Set.of("org1", "loc1"))) + .assertNext( + result -> { + assertThat(result).containsEntry("org1", "org-pseudo"); + assertThat(result).containsEntry("loc1", "loc-pseudo"); + }) + .verifyComplete(); + + wireMock.resetMappings(); + } + } + + @Nested + class GenerateTransportMappingWithNonCompartmentTests { + + @Test + void generateTransportMappingWithNonCompartmentIds(WireMockRuntimeInfo wireMockRuntime) + throws IOException { + var address = wireMockRuntime.getHttpBaseUrl(); + var wireMock = wireMockRuntime.getWireMock(); + + // Mock responses for patient pseudonym, salt, dateShift + var patientPseudonymGen = + FhirGenerators.gpasGetOrCreateResponse( + fromList(List.of("id1")), fromList(List.of("patient-pseudo"))); + var saltGen = + FhirGenerators.gpasGetOrCreateResponse( + fromList(List.of("Salt_id1")), fromList(List.of("salt-value"))); + var dateShiftGen = + FhirGenerators.gpasGetOrCreateResponse( + fromList(List.of("PT336H_id1")), fromList(List.of("dateshift-seed"))); + // Mock response for non-compartment IDs (Organization) + var nonCompartmentGen = + FhirGenerators.gpasGetOrCreateResponse( + fromList(List.of("id1.Organization:org-123")), fromList(List.of("org-pseudo"))); + + // Register mocks for specific request patterns + wireMock.register( + post(urlEqualTo("/$pseudonymizeAllowCreate")) + .withRequestBody( + equalToJson( + """ + { "resourceType": "Parameters", + "parameter": [ + {"name": "target", "valueString": "domain"}, + {"name": "original", "valueString": "id1"}]} + """, + true, + true)) + .willReturn(fhirResponse(patientPseudonymGen.generateString()))); + + wireMock.register( + post(urlEqualTo("/$pseudonymizeAllowCreate")) + .withRequestBody( + equalToJson( + """ + { "resourceType": "Parameters", + "parameter": [ + {"name": "target", "valueString": "domain"}, + {"name": "original", "valueString": "Salt_id1"}]} + """, + true, + true)) + .willReturn(fhirResponse(saltGen.generateString()))); + + wireMock.register( + post(urlEqualTo("/$pseudonymizeAllowCreate")) + .withRequestBody( + equalToJson( + """ + { "resourceType": "Parameters", + "parameter": [ + {"name": "target", "valueString": "domain"}, + {"name": "original", "valueString": "PT336H_id1"}]} + """, + true, + true)) + .willReturn(fhirResponse(dateShiftGen.generateString()))); + + // Mock for batch non-compartment call (Organization has no patient prefix) + var nonCompartmentGenNew = + FhirGenerators.gpasGetOrCreateResponse( + fromList(List.of("Organization:org-123")), fromList(List.of("org-pseudo"))); + wireMock.register( + post(urlEqualTo("/$pseudonymizeAllowCreate")) + .withRequestBody( + equalToJson( + """ + { "resourceType": "Parameters", + "parameter": [ + {"name": "target", "valueString": "domain"}, + {"name": "original", "valueString": "Organization:org-123"}]} + """, + true, + true)) + .willReturn(fhirResponse(nonCompartmentGenNew.generateString()))); + + given(redisClient.reactive()).willReturn(redis); + given(redis.getMapCache(anyString())).willReturn(mapCache); + given(mapCache.expire(Duration.ofMinutes(10))).willReturn(Mono.just(false)); + given(mapCache.putAll(anyMap())).willReturn(Mono.empty()); + + var gpasConfig = new GpasDeIdentificationConfiguration(); + var gpasClient = + new GpasClient(httpClientBuilder.baseUrl(address).build(), meterRegistry, gpasConfig); + var provider = + new FhirMappingProvider( + gpasClient, + redisClient, + transportMappingConfiguration, + meterRegistry, + new RandomStringGenerator(new Random(0))); + + // Non-compartment resources (Organization) sent separately + var compartmentIds = + Set.of("id1.Patient:patient-resource-id", "id1.identifier.patientIdentifierSystem:id1"); + var nonCompartmentIds = Set.of("Organization:org-123"); + + var request = + new TransportMappingRequest( + "id1", + "patientIdentifierSystem", + compartmentIds, + nonCompartmentIds, + DEFAULT_DOMAINS, + Duration.ofDays(14), + DateShiftPreserve.NONE); + + var allIds = new java.util.HashSet<>(compartmentIds); + allIds.addAll(nonCompartmentIds); + + create(provider.generateTransportMapping(request)) + .assertNext( + r -> { + assertThat(r.transportMapping().keySet()).isEqualTo(allIds); + assertThat(r.transportMapping()).hasSize(3); + }) + .verifyComplete(); + + wireMock.resetMappings(); + } + } + @AfterEach void tearDown() { wireMock.resetMappings(); diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerIT.java index d0619381d..dc84525fd 100644 --- a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerIT.java +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerIT.java @@ -95,7 +95,8 @@ void successfulRequest() throws IOException { ofEntries( entry("tcaDomains", DEFAULT_DOMAINS), entry("patientId", "id-144218"), - entry("resourceIds", Set.of("id-144218", "id-244194")), + entry("compartmentResourceIds", Set.of("id-144218", "id-244194")), + entry("nonCompartmentResourceIds", Set.of()), entry("maxDateShift", ofDays(14).getSeconds()), entry("dateShiftPreserve", "NONE"))); @@ -139,7 +140,8 @@ void firstRequestToGpasFails() throws IOException { ofEntries( entry("tcaDomains", DEFAULT_DOMAINS), entry("patientId", "id-144218"), - entry("resourceIds", Set.of("id-144218", "id-244194")), + entry("compartmentResourceIds", Set.of("id-144218", "id-244194")), + entry("nonCompartmentResourceIds", Set.of()), entry("maxDateShift", ofDays(14).getSeconds()), entry("dateShiftPreserve", "NONE"))); @@ -207,7 +209,8 @@ void transportMappingIdsAndDateShiftingValuesAndFetchPseudonyms() throws IOExcep ofEntries( entry("tcaDomains", DEFAULT_DOMAINS), entry("patientId", "id-144218"), - entry("resourceIds", Set.of("id-144218", "id-244194")), + entry("compartmentResourceIds", Set.of("id-144218", "id-244194")), + entry("nonCompartmentResourceIds", Set.of()), entry("maxDateShift", ofDays(14).getSeconds()), entry("dateShiftPreserve", "NONE"))) .block() diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerTest.java index 9ea053222..03feb4fcf 100644 --- a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerTest.java +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/DeIdentificationControllerTest.java @@ -51,6 +51,7 @@ void transportMapping() { "patientId1", "patientIdentifierSystem", ids, + Set.of(), DEFAULT_DOMAINS, ofDays(14), DateShiftPreserve.NONE); @@ -81,6 +82,7 @@ void transportMappingUnknownDomain() { "id1", "patientIdentifierSystem", Set.of("id1"), + Set.of(), domains, ofDays(14), DateShiftPreserve.NONE); @@ -102,6 +104,7 @@ void transportMappingIllegalArgumentException() { "id1", "patientIdentifierSystem", Set.of("id1"), + Set.of(), DEFAULT_DOMAINS, ofDays(14), DateShiftPreserve.NONE); @@ -125,6 +128,7 @@ void transportMappingEmptyIds() { "patientId1", "patientIdentifierSystem", Set.of(), + Set.of(), DEFAULT_DOMAINS, ofDays(14), DateShiftPreserve.NONE); @@ -150,6 +154,7 @@ void transportMappingInternalServerError() { "id1", "patientIdentifierSystem", ids, + Set.of(), DEFAULT_DOMAINS, ofDays(14), DateShiftPreserve.NONE); diff --git a/util/src/main/java/care/smith/fts/util/tca/TransportMappingRequest.java b/util/src/main/java/care/smith/fts/util/tca/TransportMappingRequest.java index 2673a353e..5342f379a 100644 --- a/util/src/main/java/care/smith/fts/util/tca/TransportMappingRequest.java +++ b/util/src/main/java/care/smith/fts/util/tca/TransportMappingRequest.java @@ -8,7 +8,8 @@ public record TransportMappingRequest( @NotNull(groups = TransportMappingRequest.class) String patientId, @NotNull(groups = TransportMappingRequest.class) String patientIdentifierSystem, - @NotNull(groups = TransportMappingRequest.class) Set resourceIds, + @NotNull(groups = TransportMappingRequest.class) Set compartmentResourceIds, + @NotNull(groups = TransportMappingRequest.class) Set nonCompartmentResourceIds, @NotNull(groups = TransportMappingRequest.class) TcaDomains tcaDomains, @NotNull(groups = TransportMappingRequest.class) Duration maxDateShift, @NotNull(groups = TransportMappingRequest.class) DateShiftPreserve dateShiftPreserve) {} diff --git a/util/src/test/java/care/smith/fts/util/tca/TransportMappingRequestTest.java b/util/src/test/java/care/smith/fts/util/tca/TransportMappingRequestTest.java index ba52c2cbb..237e4e159 100644 --- a/util/src/test/java/care/smith/fts/util/tca/TransportMappingRequestTest.java +++ b/util/src/test/java/care/smith/fts/util/tca/TransportMappingRequestTest.java @@ -22,6 +22,7 @@ void serialize() throws JsonProcessingException { "patient123", "patientIdentifierSystem", Set.of("id1", "id2"), + Set.of("id3"), new TcaDomains("pDomain", "sDomain", "dDomain"), Duration.ofDays(30), DateShiftPreserve.NONE); @@ -33,6 +34,7 @@ void serialize() throws JsonProcessingException { .contains("patientIdentifierSystem") .contains("id1") .contains("id2") + .contains("id3") .contains("pDomain") .contains("sDomain") .contains("dDomain") @@ -46,7 +48,8 @@ void deserialize() throws JsonProcessingException { { "patientId": "patient123", "patientIdentifierSystem": "patientIdentifierSystem", - "resourceIds": ["id1", "id2"], + "compartmentResourceIds": ["id1", "id2"], + "nonCompartmentResourceIds": ["id3"], "tcaDomains": { "pseudonym" : "pDomain", "salt" : "sDomain", @@ -59,7 +62,8 @@ void deserialize() throws JsonProcessingException { TransportMappingRequest request = objectMapper.readValue(json, TransportMappingRequest.class); assertThat(request.patientId()).isEqualTo("patient123"); - assertThat(request.resourceIds()).containsExactlyInAnyOrder("id1", "id2"); + assertThat(request.compartmentResourceIds()).containsExactlyInAnyOrder("id1", "id2"); + assertThat(request.nonCompartmentResourceIds()).containsExactly("id3"); assertThat(request.tcaDomains().pseudonym()).isEqualTo("pDomain"); assertThat(request.tcaDomains().salt()).isEqualTo("sDomain"); assertThat(request.tcaDomains().dateShift()).isEqualTo("dDomain");