diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java new file mode 100644 index 000000000..fa5aa3749 --- /dev/null +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java @@ -0,0 +1,48 @@ +package care.smith.fts.cda.impl; + +import care.smith.fts.util.HttpClientConfig; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.util.Optional; + +/** + * Configuration for FHIR Pseudonymizer service integration in Clinical Domain Agent. + * + *

This configuration enables deidentification via an external FHIR Pseudonymizer service, which + * delegates pseudonym generation to the Trust Center Agent's Vfps-compatible FHIR operations. + * + *

Configuration example: + * + *

{@code
+ * deidentificator:
+ *   fhir-pseudonymizer:
+ *     server:
+ *       baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
+ *       auth:
+ *         type: oauth2
+ *         clientId: "cda-client"
+ *         clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ *     timeout: 60s
+ *     maxRetries: 3
+ * }
+ * + * @param server HTTP client configuration for the FHIR Pseudonymizer service + * @param timeout Request timeout (default: 60 seconds) + * @param maxRetries Maximum retry attempts (default: 3) + */ +public record FhirPseudonymizerConfig( + @NotNull HttpClientConfig server, Duration timeout, Integer maxRetries) { + + public FhirPseudonymizerConfig(HttpClientConfig server, Duration timeout, Integer maxRetries) { + this.server = server; + this.timeout = Optional.ofNullable(timeout).orElse(Duration.ofSeconds(60)); + this.maxRetries = Optional.ofNullable(maxRetries).orElse(3); + + if (this.timeout.isNegative() || this.timeout.isZero()) { + throw new IllegalArgumentException("Timeout must be positive"); + } + if (this.maxRetries < 0) { + throw new IllegalArgumentException("Max retries must be non-negative"); + } + } +} diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java new file mode 100644 index 000000000..5e90478ef --- /dev/null +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java @@ -0,0 +1,121 @@ +package care.smith.fts.cda.impl; + +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON; +import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.ConsentedPatientBundle; +import care.smith.fts.api.TransportBundle; +import care.smith.fts.api.cda.Deidentificator; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Bundle; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * Deidentificator implementation that delegates deidentification to an external FHIR Pseudonymizer + * service. + * + *

This implementation provides an alternative to DeidentifhirStep, enabling deidentification via + * a configurable external service. The FHIR Pseudonymizer service internally calls the TCA's + * Vfps-compatible FHIR operations for transport ID generation. + * + *

Architecture: + * + *

+ * ConsentedPatientBundle
+ *     ↓
+ * FhirPseudonymizerStep (this class)
+ *     ↓ [HTTP POST /fhir with FHIR Bundle]
+ * FHIR Pseudonymizer Service (external, clinical domain)
+ *     ↓ [POST /$create-pseudonym]
+ * TCA CdAgentFhirPseudonymizerController
+ *     ↓ [transport ID generation, sID stored in Redis]
+ * TransportBundle (with transport IDs)
+ * 
+ * + *

The transport IDs in the returned bundle are temporary identifiers that will be resolved to + * real pseudonyms by the RDA via TCA's /rd-agent/fhir endpoint. + */ +@Slf4j +public class FhirPseudonymizerStep implements Deidentificator { + + private static final String FHIR_ENDPOINT = "/fhir"; + + private final WebClient fhirPseudonymizerClient; + private final FhirPseudonymizerConfig config; + private final MeterRegistry meterRegistry; + private final FhirContext fhirContext; + + public FhirPseudonymizerStep( + WebClient fhirPseudonymizerClient, + FhirPseudonymizerConfig config, + MeterRegistry meterRegistry, + FhirContext fhirContext) { + this.fhirPseudonymizerClient = fhirPseudonymizerClient; + this.config = config; + this.meterRegistry = meterRegistry; + this.fhirContext = fhirContext; + } + + /** + * Deidentifies a FHIR Bundle via external FHIR Pseudonymizer service. + * + *

Sends the ConsentedPatientBundle to the FHIR Pseudonymizer service, which processes the + * bundle and returns a deidentified version containing transport IDs instead of real pseudonyms. + * + * @param bundle ConsentedPatientBundle containing patient data to deidentify + * @return Mono of TransportBundle with deidentified bundle and transfer ID + */ + @Override + public Mono deidentify(ConsentedPatientBundle bundle) { + log.debug( + "Deidentifying bundle for patient {} via FHIR Pseudonymizer", + bundle.consentedPatient().id()); + + String bundleJson = fhirContext.newJsonParser().encodeResourceToString(bundle.bundle()); + + return fhirPseudonymizerClient + .post() + .uri(FHIR_ENDPOINT) + .contentType(APPLICATION_FHIR_JSON) + .accept(APPLICATION_FHIR_JSON) + .bodyValue(bundleJson) + .retrieve() + .bodyToMono(String.class) + .map(this::parseDeidentifiedBundle) + .map(this::createTransportBundle) + .timeout(config.timeout()) + .retryWhen(defaultRetryStrategy(meterRegistry, "fhirPseudonymizerDeidentification")) + .doOnSuccess( + result -> + log.debug( + "Successfully deidentified bundle for patient {}, transfer ID: {}", + bundle.consentedPatient().id(), + result.transferId())) + .doOnError( + error -> + log.error( + "Failed to deidentify bundle for patient {}: {}", + bundle.consentedPatient().id(), + error.getMessage())); + } + + private Bundle parseDeidentifiedBundle(String bundleJson) { + return fhirContext.newJsonParser().parseResource(Bundle.class, bundleJson); + } + + private TransportBundle createTransportBundle(Bundle deidentifiedBundle) { + var transferId = deidentifiedBundle.getIdPart(); + + // Note: HAPI FHIR's getIdPart() returns null for empty/missing IDs, never empty string + if (transferId == null) { + throw new IllegalStateException( + "FHIR Pseudonymizer returned bundle without transfer ID (bundle.id is null)"); + } + + log.trace("Extracted transfer ID from deidentified bundle: {}", transferId); + return new TransportBundle(deidentifiedBundle, transferId); + } +} diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java new file mode 100644 index 000000000..0d9b8e20a --- /dev/null +++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java @@ -0,0 +1,65 @@ +package care.smith.fts.cda.impl; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.cda.Deidentificator; +import care.smith.fts.util.WebClientFactory; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Factory for creating FhirPseudonymizerStep instances in the Clinical Domain Agent. + * + *

This factory implements the transfer process step factory pattern, enabling + * configuration-based instantiation of FHIR Pseudonymizer deidentificators. + * + *

Configuration example: + * + *

{@code
+ * deidentificator:
+ *   fhir-pseudonymizer:
+ *     server:
+ *       baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
+ *       auth:
+ *         type: oauth2
+ *         clientId: "cda-client"
+ *         clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ *     timeout: 60s
+ *     maxRetries: 3
+ * }
+ * + *

The factory is registered as a Spring bean with name "fhir-pseudonymizerDeidentificator" to + * enable configuration-based selection between deidentification methods (deidentifhir vs + * fhir-pseudonymizer). + */ +@Slf4j +@Component("fhir-pseudonymizerDeidentificator") +public class FhirPseudonymizerStepFactory + implements Deidentificator.Factory { + + private final WebClientFactory clientFactory; + private final MeterRegistry meterRegistry; + private final FhirContext fhirContext; + + public FhirPseudonymizerStepFactory( + WebClientFactory clientFactory, MeterRegistry meterRegistry, FhirContext fhirContext) { + this.clientFactory = clientFactory; + this.meterRegistry = meterRegistry; + this.fhirContext = fhirContext; + } + + @Override + public Class getConfigType() { + return FhirPseudonymizerConfig.class; + } + + @Override + public Deidentificator create( + Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) { + var httpClient = clientFactory.create(implConfig.server()); + + log.info("Created FhirPseudonymizerStep with service URL: {}", implConfig.server().baseUrl()); + + return new FhirPseudonymizerStep(httpClient, implConfig, meterRegistry, fhirContext); + } +} diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java new file mode 100644 index 000000000..b3a312ea2 --- /dev/null +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java @@ -0,0 +1,88 @@ +package care.smith.fts.cda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import care.smith.fts.util.HttpClientConfig; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class FhirPseudonymizerConfigTest { + + @Test + void createWithAllParametersExplicit() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var timeout = Duration.ofSeconds(30); + var maxRetries = 5; + + var config = new FhirPseudonymizerConfig(server, timeout, maxRetries); + + assertThat(config.server()).isEqualTo(server); + assertThat(config.timeout()).isEqualTo(timeout); + assertThat(config.maxRetries()).isEqualTo(5); + } + + @Test + void createWithDefaultTimeout() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, null, 5); + + assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + void createWithDefaultMaxRetries() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), null); + + assertThat(config.maxRetries()).isEqualTo(3); + } + + @Test + void createWithAllDefaults() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, null, null); + + assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(config.maxRetries()).isEqualTo(3); + } + + @Test + void negativeTimeoutThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(-1), 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Timeout must be positive"); + } + + @Test + void zeroTimeoutThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ZERO, 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Timeout must be positive"); + } + + @Test + void negativeMaxRetriesThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Max retries must be non-negative"); + } + + @Test + void zeroMaxRetriesIsAllowed() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 0); + + assertThat(config.maxRetries()).isZero(); + } +} diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java new file mode 100644 index 000000000..061944f43 --- /dev/null +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java @@ -0,0 +1,70 @@ +package care.smith.fts.cda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.cda.Deidentificator; +import care.smith.fts.util.HttpClientConfig; +import care.smith.fts.util.WebClientFactory; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; + +@ExtendWith(MockitoExtension.class) +class FhirPseudonymizerStepFactoryTest { + + @Mock private WebClientFactory clientFactory; + @Mock private WebClient webClient; + + private MeterRegistry meterRegistry; + private FhirContext fhirContext; + private FhirPseudonymizerStepFactory factory; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + fhirContext = FhirContext.forR4(); + factory = new FhirPseudonymizerStepFactory(clientFactory, meterRegistry, fhirContext); + } + + @Test + void getConfigTypeReturnsFhirPseudonymizerConfigClass() { + assertThat(factory.getConfigType()).isEqualTo(FhirPseudonymizerConfig.class); + } + + @Test + void createReturnsDeidentificator() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var implConfig = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 3); + var commonConfig = new Deidentificator.Config(); + + when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient); + + var result = factory.create(commonConfig, implConfig); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(FhirPseudonymizerStep.class); + } + + @Test + void createWithDefaultConfigValues() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var implConfig = new FhirPseudonymizerConfig(server, null, null); + var commonConfig = new Deidentificator.Config(); + + when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient); + + var result = factory.create(commonConfig, implConfig); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(FhirPseudonymizerStep.class); + } +} diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java new file mode 100644 index 000000000..93eae3e86 --- /dev/null +++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java @@ -0,0 +1,253 @@ +package care.smith.fts.cda.impl; + +import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON; +import static care.smith.fts.test.MockServerUtil.clientConfig; +import static care.smith.fts.test.TestPatientGenerator.generateOnePatient; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE; +import static reactor.test.StepVerifier.create; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.ConsentedPatient; +import care.smith.fts.api.ConsentedPatientBundle; +import care.smith.fts.cda.ClinicalDomainAgent; +import care.smith.fts.util.WebClientFactory; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Bundle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Integration tests for FhirPseudonymizerStep in Clinical Domain Agent. + * + *

These tests verify: + * + *

+ */ +@Slf4j +@SpringBootTest(classes = ClinicalDomainAgent.class) +@WireMockTest +class FhirPseudonymizerStepIT { + + private static final String FHIR_PSEUDONYMIZER_ENDPOINT = "/fhir"; + + private FhirPseudonymizerStep step; + private WireMock wireMock; + private ConsentedPatientBundle testBundle; + private FhirContext fhirContext; + + @BeforeEach + void setUp( + WireMockRuntimeInfo wireMockRuntime, + @Autowired WebClientFactory clientFactory, + @Autowired MeterRegistry meterRegistry) + throws java.io.IOException { + var config = + new FhirPseudonymizerConfig(clientConfig(wireMockRuntime), Duration.ofSeconds(30), 3); + + var client = clientFactory.create(clientConfig(wireMockRuntime)); + wireMock = wireMockRuntime.getWireMock(); + fhirContext = FhirContext.forR4(); + + step = new FhirPseudonymizerStep(client, config, meterRegistry, fhirContext); + + var bundle = + generateOnePatient("patient-test-123", "2024", "http://test.example.com", "test-id"); + var consentedPatient = new ConsentedPatient("patient-test-123", "http://test.example.com"); + testBundle = new ConsentedPatientBundle(bundle, consentedPatient); + } + + @Test + void testDeidentifyWithFhirPseudonymizer() { + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + deidentifiedBundle.setId("transport-bundle-123"); + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .assertNext( + transportBundle -> { + assertThat(transportBundle).isNotNull(); + assertThat(transportBundle.bundle()).isNotNull(); + assertThat(transportBundle.transferId()).isNotEmpty(); + assertThat(transportBundle.transferId()).isEqualTo("transport-bundle-123"); + }) + .verifyComplete(); + + wireMock.verifyThat(1, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testDeidentifyServiceUnavailableRetry() { + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .willReturn( + aResponse() + .withStatus(SERVICE_UNAVAILABLE.value()) + .withBody("Service temporarily unavailable"))); + + var result = step.deidentify(testBundle); + + create(result) + .expectErrorSatisfies( + error -> { + assertThat(error).isNotNull(); + assertThat(error.getMessage()) + .satisfiesAnyOf( + msg -> assertThat(msg).contains("Service temporarily unavailable"), + msg -> assertThat(msg).contains("503"), + msg -> assertThat(msg).contains("SERVICE_UNAVAILABLE"), + msg -> assertThat(msg).contains("Retries exhausted")); + }) + .verify(); + + wireMock.verifyThat(4, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testDeidentifySuccessAfterRetry() { + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + deidentifiedBundle.setId("transport-bundle-retry-success"); + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value())) + .willSetStateTo("First Failure")); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("First Failure") + .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value())) + .willSetStateTo("Second Failure")); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Second Failure") + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .assertNext( + transportBundle -> { + assertThat(transportBundle).isNotNull(); + assertThat(transportBundle.transferId()).isEqualTo("transport-bundle-retry-success"); + }) + .verifyComplete(); + + wireMock.verifyThat(3, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testConfigurationValues() { + assertThat(step).isNotNull(); + } + + @Test + void testDeidentifyWithNullTransferIdThrowsIllegalStateException() { + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + // Note: not setting the ID leaves it null + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .expectErrorSatisfies( + error -> { + assertThat(error).isInstanceOf(IllegalStateException.class); + assertThat(error.getMessage()).contains("bundle.id is null"); + }) + .verify(); + } + + @Test + void testDeidentifyWithEmptyTransferIdThrowsIllegalStateException() { + // Note: HAPI FHIR normalizes empty IDs to null during JSON serialization/deserialization, + // so this test effectively also tests the null case. This is expected behavior. + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + deidentifiedBundle.setId(""); + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .expectErrorSatisfies( + error -> { + assertThat(error).isInstanceOf(IllegalStateException.class); + assertThat(error.getMessage()).contains("bundle.id is null"); + }) + .verify(); + } + + @AfterEach + void tearDown() { + wireMock.resetMappings(); + wireMock.resetScenarios(); + } +} diff --git a/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml b/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml new file mode 100644 index 000000000..857405eee --- /dev/null +++ b/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml @@ -0,0 +1,27 @@ +# Example project configuration using FHIR Pseudonymizer for deidentification +# This configuration demonstrates how to use the FhirPseudonymizerStep as the deidentificator + +cohortSelector: + tca: + domain: "MII" + policySystem: "http://mii.de/fhir/sid/policy" + policies: ["IDAT_erheben", "IDAT_speichern_verarbeiten"] + patientIdSystem: "http://fts.smith.care" + +dataSelector: + everything: + fhirServer: + baseUrl: "http://cd-hds:8080/fhir" + +deidentificator: + fhir-pseudonymizer: + server: + baseUrl: "http://fhir-pseudonymizer:8080" + timeout: 60s + maxRetries: 3 + +bundleSender: + rda: + server: + baseUrl: "http://rda:8080" + project: "example" diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java new file mode 100644 index 000000000..1d731ae63 --- /dev/null +++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java @@ -0,0 +1,51 @@ +package care.smith.fts.rda.impl; + +import care.smith.fts.util.HttpClientConfig; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.util.Optional; + +/** + * Configuration for FHIR Pseudonymizer service integration in Research Domain Agent. + * + *

This configuration enables deidentification via an external FHIR Pseudonymizer service, which + * delegates pseudonym resolution to the Trust Center Agent's Vfps-compatible FHIR operations. + * + *

In the RDA context, the FHIR Pseudonymizer resolves transport IDs (tIDs) in the incoming + * bundle to their corresponding secure pseudonyms (sIDs) via TCA's /rd-agent/fhir endpoint. + * + *

Configuration example: + * + *

{@code
+ * deidentificator:
+ *   fhir-pseudonymizer:
+ *     server:
+ *       baseUrl: "https://fhir-pseudonymizer.research.example.com"
+ *       auth:
+ *         type: oauth2
+ *         clientId: "rda-client"
+ *         clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ *     timeout: 60s
+ *     maxRetries: 3
+ * }
+ * + * @param server HTTP client configuration for the FHIR Pseudonymizer service + * @param timeout Request timeout (default: 60 seconds) + * @param maxRetries Maximum retry attempts (default: 3) + */ +public record FhirPseudonymizerConfig( + @NotNull HttpClientConfig server, Duration timeout, Integer maxRetries) { + + public FhirPseudonymizerConfig(HttpClientConfig server, Duration timeout, Integer maxRetries) { + this.server = server; + this.timeout = Optional.ofNullable(timeout).orElse(Duration.ofSeconds(60)); + this.maxRetries = Optional.ofNullable(maxRetries).orElse(3); + + if (this.timeout.isNegative() || this.timeout.isZero()) { + throw new IllegalArgumentException("Timeout must be positive"); + } + if (this.maxRetries < 0) { + throw new IllegalArgumentException("Max retries must be non-negative"); + } + } +} diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java new file mode 100644 index 000000000..68bb0f934 --- /dev/null +++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java @@ -0,0 +1,106 @@ +package care.smith.fts.rda.impl; + +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON; +import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.TransportBundle; +import care.smith.fts.api.rda.Deidentificator; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Bundle; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +/** + * RDA Deidentificator implementation that delegates deidentification to an external FHIR + * Pseudonymizer service. + * + *

This implementation provides an alternative to DeidentifhirStep in the Research Domain Agent. + * It sends bundles containing transport IDs to a local FHIR Pseudonymizer service, which resolves + * the tIDs to secure pseudonyms (sIDs) via TCA's /rd-agent/fhir endpoint. + * + *

Architecture: + * + *

+ * TransportBundle (with transport IDs)
+ *     ↓
+ * FhirPseudonymizerStep (this class)
+ *     ↓ [HTTP POST /fhir with FHIR Bundle]
+ * FHIR Pseudonymizer Service (external, research domain)
+ *     ↓ [POST /$create-pseudonym]
+ * TCA RdAgentFhirPseudonymizerController
+ *     ↓ [tID→sID resolution from Redis]
+ * Bundle (with final sIDs)
+ * 
+ * + *

The returned bundle contains real secure pseudonyms (sIDs) ready for storage in the research + * FHIR store. + */ +@Slf4j +public class FhirPseudonymizerStep implements Deidentificator { + + private static final String FHIR_ENDPOINT = "/fhir"; + + private final WebClient fhirPseudonymizerClient; + private final FhirPseudonymizerConfig config; + private final MeterRegistry meterRegistry; + private final FhirContext fhirContext; + + public FhirPseudonymizerStep( + WebClient fhirPseudonymizerClient, + FhirPseudonymizerConfig config, + MeterRegistry meterRegistry, + FhirContext fhirContext) { + this.fhirPseudonymizerClient = fhirPseudonymizerClient; + this.config = config; + this.meterRegistry = meterRegistry; + this.fhirContext = fhirContext; + } + + /** + * Deidentifies a FHIR Bundle via external FHIR Pseudonymizer service. + * + *

Sends the TransportBundle to the FHIR Pseudonymizer service, which resolves transport IDs to + * secure pseudonyms via TCA and returns the final deidentified bundle. + * + * @param bundle TransportBundle containing data with transport IDs + * @return Mono of Bundle with resolved secure pseudonyms (sIDs) + */ + @Override + public Mono deidentify(TransportBundle bundle) { + log.debug( + "Resolving transport IDs in bundle via FHIR Pseudonymizer, transfer ID: {}", + bundle.transferId()); + + String bundleJson = fhirContext.newJsonParser().encodeResourceToString(bundle.bundle()); + + return fhirPseudonymizerClient + .post() + .uri(FHIR_ENDPOINT) + .contentType(APPLICATION_FHIR_JSON) + .accept(APPLICATION_FHIR_JSON) + .bodyValue(bundleJson) + .retrieve() + .bodyToMono(String.class) + .map(this::parseDeidentifiedBundle) + .timeout(config.timeout()) + .retryWhen(defaultRetryStrategy(meterRegistry, "rdaFhirPseudonymizerDeidentification")) + .doOnSuccess( + result -> + log.debug( + "Successfully resolved transport IDs for transfer ID: {}, bundle entries: {}", + bundle.transferId(), + result.getEntry().size())) + .doOnError( + error -> + log.error( + "Failed to resolve transport IDs for transfer ID {}: {}", + bundle.transferId(), + error.getMessage())); + } + + private Bundle parseDeidentifiedBundle(String bundleJson) { + return fhirContext.newJsonParser().parseResource(Bundle.class, bundleJson); + } +} diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java new file mode 100644 index 000000000..db0e7d2a3 --- /dev/null +++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java @@ -0,0 +1,66 @@ +package care.smith.fts.rda.impl; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.rda.Deidentificator; +import care.smith.fts.util.WebClientFactory; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Factory for creating FhirPseudonymizerStep instances in the Research Domain Agent. + * + *

This factory implements the transfer process step factory pattern, enabling + * configuration-based instantiation of FHIR Pseudonymizer deidentificators for tID→sID resolution. + * + *

Configuration example: + * + *

{@code
+ * deidentificator:
+ *   fhir-pseudonymizer:
+ *     server:
+ *       baseUrl: "https://fhir-pseudonymizer.research.example.com"
+ *       auth:
+ *         type: oauth2
+ *         clientId: "rda-client"
+ *         clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ *     timeout: 60s
+ *     maxRetries: 3
+ * }
+ * + *

The factory is registered as a Spring bean with name "fhir-pseudonymizerDeidentificator" to + * enable configuration-based selection between deidentification methods (deidentifhir vs + * fhir-pseudonymizer). + */ +@Slf4j +@Component("fhir-pseudonymizerDeidentificator") +public class FhirPseudonymizerStepFactory + implements Deidentificator.Factory { + + private final WebClientFactory clientFactory; + private final MeterRegistry meterRegistry; + private final FhirContext fhirContext; + + public FhirPseudonymizerStepFactory( + WebClientFactory clientFactory, MeterRegistry meterRegistry, FhirContext fhirContext) { + this.clientFactory = clientFactory; + this.meterRegistry = meterRegistry; + this.fhirContext = fhirContext; + } + + @Override + public Class getConfigType() { + return FhirPseudonymizerConfig.class; + } + + @Override + public Deidentificator create( + Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) { + var httpClient = clientFactory.create(implConfig.server()); + + log.info( + "Created RDA FhirPseudonymizerStep with service URL: {}", implConfig.server().baseUrl()); + + return new FhirPseudonymizerStep(httpClient, implConfig, meterRegistry, fhirContext); + } +} diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java new file mode 100644 index 000000000..d8d12c08f --- /dev/null +++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java @@ -0,0 +1,88 @@ +package care.smith.fts.rda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import care.smith.fts.util.HttpClientConfig; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class FhirPseudonymizerConfigTest { + + @Test + void createWithAllParametersExplicit() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var timeout = Duration.ofSeconds(30); + var maxRetries = 5; + + var config = new FhirPseudonymizerConfig(server, timeout, maxRetries); + + assertThat(config.server()).isEqualTo(server); + assertThat(config.timeout()).isEqualTo(timeout); + assertThat(config.maxRetries()).isEqualTo(5); + } + + @Test + void createWithDefaultTimeout() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, null, 5); + + assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + void createWithDefaultMaxRetries() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), null); + + assertThat(config.maxRetries()).isEqualTo(3); + } + + @Test + void createWithAllDefaults() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, null, null); + + assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(config.maxRetries()).isEqualTo(3); + } + + @Test + void negativeTimeoutThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(-1), 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Timeout must be positive"); + } + + @Test + void zeroTimeoutThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ZERO, 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Timeout must be positive"); + } + + @Test + void negativeMaxRetriesThrowsException() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Max retries must be non-negative"); + } + + @Test + void zeroMaxRetriesIsAllowed() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + + var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 0); + + assertThat(config.maxRetries()).isZero(); + } +} diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java new file mode 100644 index 000000000..0e6438076 --- /dev/null +++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java @@ -0,0 +1,70 @@ +package care.smith.fts.rda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.rda.Deidentificator; +import care.smith.fts.util.HttpClientConfig; +import care.smith.fts.util.WebClientFactory; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; + +@ExtendWith(MockitoExtension.class) +class FhirPseudonymizerStepFactoryTest { + + @Mock private WebClientFactory clientFactory; + @Mock private WebClient webClient; + + private MeterRegistry meterRegistry; + private FhirContext fhirContext; + private FhirPseudonymizerStepFactory factory; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + fhirContext = FhirContext.forR4(); + factory = new FhirPseudonymizerStepFactory(clientFactory, meterRegistry, fhirContext); + } + + @Test + void getConfigTypeReturnsFhirPseudonymizerConfigClass() { + assertThat(factory.getConfigType()).isEqualTo(FhirPseudonymizerConfig.class); + } + + @Test + void createReturnsDeidentificator() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var implConfig = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 3); + var commonConfig = new Deidentificator.Config(); + + when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient); + + var result = factory.create(commonConfig, implConfig); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(FhirPseudonymizerStep.class); + } + + @Test + void createWithDefaultConfigValues() { + var server = new HttpClientConfig("http://fhir-pseudonymizer:8080"); + var implConfig = new FhirPseudonymizerConfig(server, null, null); + var commonConfig = new Deidentificator.Config(); + + when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient); + + var result = factory.create(commonConfig, implConfig); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(FhirPseudonymizerStep.class); + } +} diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java new file mode 100644 index 000000000..178f559fe --- /dev/null +++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java @@ -0,0 +1,193 @@ +package care.smith.fts.rda.impl; + +import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON; +import static care.smith.fts.test.MockServerUtil.clientConfig; +import static care.smith.fts.test.TestPatientGenerator.generateOnePatient; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE; +import static reactor.test.StepVerifier.create; + +import ca.uhn.fhir.context.FhirContext; +import care.smith.fts.api.TransportBundle; +import care.smith.fts.rda.ResearchDomainAgent; +import care.smith.fts.util.WebClientFactory; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.micrometer.core.instrument.MeterRegistry; +import java.io.IOException; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Bundle; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Integration tests for FhirPseudonymizerStep in Research Domain Agent. + * + *

These tests verify: + * + *

+ */ +@Slf4j +@SpringBootTest(classes = ResearchDomainAgent.class) +@WireMockTest +class FhirPseudonymizerStepIT { + + private static final String FHIR_PSEUDONYMIZER_ENDPOINT = "/fhir"; + + private FhirPseudonymizerStep step; + private WireMock wireMock; + private TransportBundle testBundle; + private FhirContext fhirContext; + + @BeforeEach + void setUp( + WireMockRuntimeInfo wireMockRuntime, + @Autowired WebClientFactory clientFactory, + @Autowired MeterRegistry meterRegistry) + throws IOException { + var config = + new FhirPseudonymizerConfig(clientConfig(wireMockRuntime), Duration.ofSeconds(30), 3); + + var client = clientFactory.create(clientConfig(wireMockRuntime)); + wireMock = wireMockRuntime.getWireMock(); + fhirContext = FhirContext.forR4(); + + step = new FhirPseudonymizerStep(client, config, meterRegistry, fhirContext); + + var bundle = + generateOnePatient("transport-id-123", "2024", "http://test.example.com", "test-tid"); + testBundle = new TransportBundle(bundle, "transfer-session-123"); + } + + @Test + void testDeidentifyWithFhirPseudonymizer() { + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + deidentifiedBundle.setId("resolved-bundle-123"); + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .assertNext( + bundle -> { + assertThat(bundle).isNotNull(); + assertThat(bundle.getIdPart()).isEqualTo("resolved-bundle-123"); + assertThat(bundle.getType()).isEqualTo(Bundle.BundleType.COLLECTION); + }) + .verifyComplete(); + + wireMock.verifyThat(1, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testDeidentifyServiceUnavailableRetry() { + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .willReturn( + aResponse() + .withStatus(SERVICE_UNAVAILABLE.value()) + .withBody("Service temporarily unavailable"))); + + var result = step.deidentify(testBundle); + + create(result) + .expectErrorSatisfies( + error -> { + assertThat(error).isNotNull(); + assertThat(error.getMessage()) + .satisfiesAnyOf( + msg -> assertThat(msg).contains("Service temporarily unavailable"), + msg -> assertThat(msg).contains("503"), + msg -> assertThat(msg).contains("SERVICE_UNAVAILABLE"), + msg -> assertThat(msg).contains("Retries exhausted")); + }) + .verify(); + + wireMock.verifyThat(4, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testDeidentifySuccessAfterRetry() { + var deidentifiedBundle = new Bundle(); + deidentifiedBundle.setType(Bundle.BundleType.COLLECTION); + deidentifiedBundle.setId("resolved-bundle-retry-success"); + + var deidentifiedBundleJson = + fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value())) + .willSetStateTo("First Failure")); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("First Failure") + .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value())) + .willSetStateTo("Second Failure")); + + wireMock.register( + post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Second Failure") + .willReturn( + aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .withBody(deidentifiedBundleJson))); + + var result = step.deidentify(testBundle); + + create(result) + .assertNext( + bundle -> { + assertThat(bundle).isNotNull(); + assertThat(bundle.getIdPart()).isEqualTo("resolved-bundle-retry-success"); + }) + .verifyComplete(); + + wireMock.verifyThat(3, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))); + } + + @Test + void testConfigurationValues() { + assertThat(step).isNotNull(); + } + + @AfterEach + void tearDown() { + wireMock.resetMappings(); + wireMock.resetScenarios(); + } +} diff --git a/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml b/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml new file mode 100644 index 000000000..aeaf215b7 --- /dev/null +++ b/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml @@ -0,0 +1,15 @@ +# Example project configuration using FHIR Pseudonymizer for transport ID resolution +# This configuration demonstrates how to use the FhirPseudonymizerStep in RDA context +# to resolve transport IDs (tIDs) to secure pseudonyms (sIDs) via TCA + +deidentificator: + fhir-pseudonymizer: + server: + baseUrl: "http://fhir-pseudonymizer:8080" + timeout: 60s + maxRetries: 3 + +bundleSender: + fhirStore: + fhirServer: + baseUrl: "http://rd-hds:8080/fhir" diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java new file mode 100644 index 000000000..c6c002835 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java @@ -0,0 +1,47 @@ +package care.smith.fts.tca.adapters; + +import care.smith.fts.tca.deidentification.EnticiClient; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +/** + * Backend adapter for Entici pseudonymization service. + * + *

This adapter communicates with an external Entici service to generate pseudonyms. It + * implements the {@link PseudonymBackendAdapter} interface for use with the FHIR Pseudonymizer + * integration. + */ +@Slf4j +public class EnticiBackendAdapter implements PseudonymBackendAdapter { + + private static final String BACKEND_TYPE = "entici"; + private final EnticiClient enticiClient; + + public EnticiBackendAdapter(EnticiClient enticiClient) { + this.enticiClient = enticiClient; + } + + @Override + public Mono fetchOrCreatePseudonym(String domain, String originalValue) { + log.trace("Fetching pseudonym from Entici: domain={}, originalValue={}", domain, originalValue); + return enticiClient + .fetchOrCreatePseudonym(domain, originalValue) + .doOnSuccess(p -> log.trace("Entici returned pseudonym for {}", originalValue)); + } + + @Override + public Mono> fetchOrCreatePseudonyms( + String domain, Set originalValues) { + log.trace("Fetching {} pseudonyms from Entici: domain={}", originalValues.size(), domain); + return enticiClient + .fetchOrCreatePseudonyms(domain, originalValues) + .doOnSuccess(m -> log.trace("Entici returned {} pseudonyms", m.size())); + } + + @Override + public String getBackendType() { + return BACKEND_TYPE; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java new file mode 100644 index 000000000..dac56023b --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java @@ -0,0 +1,59 @@ +package care.smith.fts.tca.adapters; + +import care.smith.fts.tca.deidentification.GpasClient; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +/** + * Backend adapter for gPAS (generic Pseudonym Administration Service). + * + *

This adapter wraps the existing {@link GpasClient} to implement the {@link + * PseudonymBackendAdapter} interface, enabling gPAS to be used as a backend for the FHIR + * Pseudonymizer integration. + * + *

The adapter reuses all existing gPAS configuration and batching logic from GpasClient. + */ +@Slf4j +public class GpasBackendAdapter implements PseudonymBackendAdapter { + + private static final String BACKEND_TYPE = "gpas"; + + private final GpasClient gpasClient; + + public GpasBackendAdapter(GpasClient gpasClient) { + this.gpasClient = gpasClient; + } + + @Override + public Mono fetchOrCreatePseudonym(String domain, String originalValue) { + log.trace("Fetching pseudonym from gPAS: domain={}, originalValue={}", domain, originalValue); + return gpasClient + .fetchOrCreatePseudonyms(domain, Set.of(originalValue)) + .map(mappings -> mappings.get(originalValue)) + .doOnSuccess( + pseudonym -> + log.trace( + "Received pseudonym from gPAS: original={} -> pseudonym={}", + originalValue, + pseudonym)); + } + + @Override + public Mono> fetchOrCreatePseudonyms( + String domain, Set originalValues) { + log.trace("Fetching {} pseudonyms from gPAS: domain={}", originalValues.size(), domain); + return gpasClient + .fetchOrCreatePseudonyms(domain, originalValues) + .doOnSuccess( + mappings -> + log.trace( + "Received {} pseudonyms from gPAS for domain={}", mappings.size(), domain)); + } + + @Override + public String getBackendType() { + return BACKEND_TYPE; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java new file mode 100644 index 000000000..697664d21 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java @@ -0,0 +1,45 @@ +package care.smith.fts.tca.adapters; + +import java.util.Map; +import java.util.Set; +import reactor.core.publisher.Mono; + +/** + * Interface for pseudonymization backend adapters. + * + *

This interface defines the contract for adapters that communicate with pseudonymization + * backends such as gPAS, Vfps, or entici. Each adapter implementation translates requests to the + * backend-specific protocol and returns the generated pseudonyms. + * + *

All operations are reactive and return {@link Mono} to support non-blocking processing. + */ +public interface PseudonymBackendAdapter { + + /** + * Fetches or creates a single pseudonym for the given original value in the specified domain. + * + * @param domain the pseudonymization domain/namespace + * @param originalValue the original value to pseudonymize + * @return a Mono emitting the generated pseudonym (sID) + */ + Mono fetchOrCreatePseudonym(String domain, String originalValue); + + /** + * Fetches or creates pseudonyms for multiple original values in the specified domain. + * + *

This batch operation is more efficient than calling {@link #fetchOrCreatePseudonym} multiple + * times when processing many identifiers. + * + * @param domain the pseudonymization domain/namespace + * @param originalValues the set of original values to pseudonymize + * @return a Mono emitting a map from original value to generated pseudonym (sID) + */ + Mono> fetchOrCreatePseudonyms(String domain, Set originalValues); + + /** + * Returns the backend type identifier for this adapter. + * + * @return the backend type (e.g., "gpas", "vfps", "entici") + */ + String getBackendType(); +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java new file mode 100644 index 000000000..618fb71ad --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java @@ -0,0 +1,46 @@ +package care.smith.fts.tca.adapters; + +import care.smith.fts.tca.deidentification.VfpsClient; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +/** + * Backend adapter for Vfps (Very Fast Pseudonym Service). + * + *

This adapter communicates with an external Vfps service to generate pseudonyms. It implements + * the {@link PseudonymBackendAdapter} interface for use with the FHIR Pseudonymizer integration. + */ +@Slf4j +public class VfpsBackendAdapter implements PseudonymBackendAdapter { + + private static final String BACKEND_TYPE = "vfps"; + private final VfpsClient vfpsClient; + + public VfpsBackendAdapter(VfpsClient vfpsClient) { + this.vfpsClient = vfpsClient; + } + + @Override + public Mono fetchOrCreatePseudonym(String domain, String originalValue) { + log.trace("Fetching pseudonym from Vfps: domain={}, originalValue={}", domain, originalValue); + return vfpsClient + .fetchOrCreatePseudonym(domain, originalValue) + .doOnSuccess(p -> log.trace("Vfps returned pseudonym for {}", originalValue)); + } + + @Override + public Mono> fetchOrCreatePseudonyms( + String domain, Set originalValues) { + log.trace("Fetching {} pseudonyms from Vfps: domain={}", originalValues.size(), domain); + return vfpsClient + .fetchOrCreatePseudonyms(domain, originalValues) + .doOnSuccess(m -> log.trace("Vfps returned {} pseudonyms", m.size())); + } + + @Override + public String getBackendType() { + return BACKEND_TYPE; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java b/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java new file mode 100644 index 000000000..8f1d43c07 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java @@ -0,0 +1,103 @@ +package care.smith.fts.tca.config; + +import care.smith.fts.util.HttpClientConfig; +import jakarta.validation.constraints.NotNull; +import java.util.Optional; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for pseudonymization backend adapter selection. + * + *

This configuration allows selection between different pseudonymization backends: gPAS, Vfps, + * or entici. Only one backend can be active at a time. + * + *

Example configuration: + * + *

+ * de-identification:
+ *   backend:
+ *     type: gpas  # or: vfps, entici
+ *     gpas:
+ *       fhir:
+ *         baseUrl: http://gpas:8080/ttp-fhir/fhir/gpas
+ *     vfps:
+ *       address: dns:///vfps:8081
+ *     entici:
+ *       baseUrl: http://entici:8080
+ * 
+ */ +@Configuration +@ConfigurationProperties(prefix = "de-identification.backend") +@Getter +@Setter +public class BackendAdapterConfig { + + /** The type of backend to use for pseudonymization. */ + @NotNull private BackendType type = BackendType.GPAS; + + /** gPAS-specific configuration (used when type=gpas). */ + private GpasConfig gpas; + + /** Vfps-specific configuration (used when type=vfps). */ + private VfpsConfig vfps; + + /** entici-specific configuration (used when type=entici). */ + private EnticiConfig entici; + + /** Supported backend types for pseudonymization. */ + public enum BackendType { + GPAS, + VFPS, + ENTICI + } + + /** + * Configuration for gPAS backend. + * + *

Note: gPAS configuration is already handled by GpasDeIdentificationConfiguration. This + * provides an alternative path for the backend adapter pattern. + */ + @Getter + @Setter + public static class GpasConfig { + private HttpClientConfig fhir; + } + + /** Configuration for Vfps (Very Fast Pseudonym Service) backend. */ + @Getter + @Setter + public static class VfpsConfig { + /** The gRPC or REST address of the Vfps service. */ + private String address; + + /** Optional authentication configuration. */ + private HttpClientConfig auth; + } + + /** Configuration for entici backend. */ + @Getter + @Setter + public static class EnticiConfig { + /** The base URL of the entici service. */ + private String baseUrl; + + /** Optional HTTP client configuration. */ + private HttpClientConfig server; + } + + /** + * Gets the active backend configuration based on the selected type. + * + * @return Optional containing the active backend config, or empty if not configured + */ + public Optional getActiveBackendConfig() { + return switch (type) { + case GPAS -> Optional.ofNullable(gpas); + case VFPS -> Optional.ofNullable(vfps); + case ENTICI -> Optional.ofNullable(entici); + }; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java new file mode 100644 index 000000000..20a14be8e --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java @@ -0,0 +1,129 @@ +package care.smith.fts.tca.deidentification; + +import static care.smith.fts.tca.TtpFhirGatewayUtil.handle4xxError; +import static care.smith.fts.tca.TtpFhirGatewayUtil.handleError; +import static care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration.ENTICI_OPERATIONS; +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON; +import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy; +import static java.util.List.of; + +import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Client for communicating with Entici pseudonymization service. + * + *

This client calls the Entici FHIR API to create pseudonyms for identifiers. Since Entici does + * not support native batch operations, batch requests are processed using client-side + * parallelization. + */ +@Slf4j +public class EnticiClient { + + private final WebClient enticiClient; + private final MeterRegistry meterRegistry; + private final int concurrency; + private final String resourceType; + private final String project; + + public EnticiClient( + WebClient enticiClient, + MeterRegistry meterRegistry, + EnticiDeIdentificationConfiguration config) { + this.enticiClient = enticiClient; + this.meterRegistry = meterRegistry; + this.concurrency = config.getConcurrency(); + this.resourceType = config.getResourceType(); + this.project = config.getProject(); + } + + /** + * Fetches or creates a pseudonym for a single identifier. + * + * @param domain the Entici domain (maps to identifier.system) + * @param originalValue the original identifier value + * @return Mono containing the pseudonym + */ + public Mono fetchOrCreatePseudonym(String domain, String originalValue) { + log.trace("Fetching pseudonym from Entici: domain={}, originalValue={}", domain, originalValue); + + var requestBody = createRequest(domain, originalValue); + + return enticiClient + .post() + .uri("/$pseudonymize") + .headers(h -> h.setContentType(APPLICATION_FHIR_JSON)) + .bodyValue(requestBody) + .headers(h -> h.setAccept(of(APPLICATION_FHIR_JSON))) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + r -> handle4xxError("Entici", enticiClient, ENTICI_OPERATIONS, r)) + .bodyToMono(EnticiParameterResponse.class) + .retryWhen(defaultRetryStrategy(meterRegistry, "fetchOrCreatePseudonymOnEntici")) + .onErrorResume(e -> handleError("Entici", e)) + .doOnError(e -> log.error("Unable to fetch pseudonym from Entici: {}", e.getMessage())) + .doOnNext(r -> log.trace("$pseudonymize response: {}", r.parameter())) + .map(EnticiParameterResponse::getPseudonymValue); + } + + /** + * Fetches or creates pseudonyms for multiple identifiers. + * + *

Since Entici does not support native batch operations, this method processes requests in + * parallel with configurable concurrency. + * + * @param domain the Entici domain (maps to identifier.system) + * @param originalValues the set of original identifier values + * @return Mono of a map from original value to pseudonym + */ + public Mono> fetchOrCreatePseudonyms( + String domain, Set originalValues) { + if (originalValues.isEmpty()) { + return Mono.just(Map.of()); + } + + log.trace("Fetching {} pseudonyms from Entici: domain={}", originalValues.size(), domain); + + return Flux.fromIterable(originalValues) + .flatMap( + original -> + fetchOrCreatePseudonym(domain, original) + .map(pseudonym -> Map.entry(original, pseudonym)), + concurrency) + .collectMap(Map.Entry::getKey, Map.Entry::getValue); + } + + private Map createRequest(String domain, String originalValue) { + List> params = new ArrayList<>(); + + // Add identifier parameter + params.add( + Map.of( + "name", + "identifier", + "valueIdentifier", + Map.of( + "system", domain, + "value", originalValue))); + + // Add resourceType parameter + params.add(Map.of("name", "resourceType", "valueString", resourceType)); + + // Add project parameter if configured + if (project != null && !project.isBlank()) { + params.add(Map.of("name", "project", "valueString", project)); + } + + return Map.of("resourceType", "Parameters", "parameter", params); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java new file mode 100644 index 000000000..752a65df1 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java @@ -0,0 +1,42 @@ +package care.smith.fts.tca.deidentification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +/** + * Record to deserialize Entici response from $pseudonymize operation. + * + *

Response format: + * + *

+ * {
+ *   "resourceType": "Parameters",
+ *   "parameter": [
+ *     {
+ *       "name": "pseudonym",
+ *       "valueIdentifier": {
+ *         "system": "domain-namespace-url",
+ *         "value": "generated-pseudonym"
+ *       }
+ *     }
+ *   ]
+ * }
+ * 
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record EnticiParameterResponse(String resourceType, List parameter) { + + public String getPseudonymValue() { + return parameter.stream() + .filter(p -> "pseudonym".equals(p.name())) + .findFirst() + .map(p -> p.valueIdentifier().value()) + .orElseThrow(() -> new IllegalStateException("No pseudonym in Entici response")); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Parameter(String name, String valueString, ValueIdentifier valueIdentifier) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ValueIdentifier(String system, String value) {} +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java new file mode 100644 index 000000000..3c10f10d3 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java @@ -0,0 +1,110 @@ +package care.smith.fts.tca.deidentification; + +import static care.smith.fts.tca.TtpFhirGatewayUtil.handle4xxError; +import static care.smith.fts.tca.TtpFhirGatewayUtil.handleError; +import static care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration.VFPS_OPERATIONS; +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON; +import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy; +import static java.util.List.of; + +import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Client for communicating with Vfps (Very Fast Pseudonym Service). + * + *

This client calls the Vfps REST API to create pseudonyms for identifiers. Since Vfps does not + * support native batch operations, batch requests are processed using client-side parallelization. + */ +@Slf4j +public class VfpsClient { + + private final WebClient vfpsClient; + private final MeterRegistry meterRegistry; + private final int concurrency; + + public VfpsClient( + WebClient vfpsClient, MeterRegistry meterRegistry, VfpsDeIdentificationConfiguration config) { + this.vfpsClient = vfpsClient; + this.meterRegistry = meterRegistry; + this.concurrency = config.getConcurrency(); + } + + /** + * Fetches or creates a pseudonym for a single identifier. + * + * @param namespace the Vfps namespace (domain) + * @param originalValue the original identifier value + * @return Mono containing the pseudonym + */ + public Mono fetchOrCreatePseudonym(String namespace, String originalValue) { + log.trace( + "Fetching pseudonym from Vfps: namespace={}, originalValue={}", namespace, originalValue); + + var requestBody = createRequest(namespace, originalValue); + + return vfpsClient + .post() + .uri("/$create-pseudonym") + .headers(h -> h.setContentType(APPLICATION_FHIR_JSON)) + .bodyValue(requestBody) + .headers(h -> h.setAccept(of(APPLICATION_FHIR_JSON))) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + r -> handle4xxError("Vfps", vfpsClient, VFPS_OPERATIONS, r)) + .bodyToMono(VfpsParameterResponse.class) + .retryWhen(defaultRetryStrategy(meterRegistry, "fetchOrCreatePseudonymOnVfps")) + .onErrorResume(e -> handleError("Vfps", e)) + .doOnError(e -> log.error("Unable to fetch pseudonym from Vfps: {}", e.getMessage())) + .doOnNext(r -> log.trace("$create-pseudonym response: {}", r.parameter())) + .map(VfpsParameterResponse::getPseudonymValue); + } + + /** + * Fetches or creates pseudonyms for multiple identifiers. + * + *

Since Vfps does not support native batch operations, this method processes requests in + * parallel with configurable concurrency. + * + * @param namespace the Vfps namespace (domain) + * @param originalValues the set of original identifier values + * @return Mono of a map from original value to pseudonym + */ + public Mono> fetchOrCreatePseudonyms( + String namespace, Set originalValues) { + if (originalValues.isEmpty()) { + return Mono.just(Map.of()); + } + + log.trace("Fetching {} pseudonyms from Vfps: namespace={}", originalValues.size(), namespace); + + return Flux.fromIterable(originalValues) + .flatMap( + original -> + fetchOrCreatePseudonym(namespace, original) + .map(pseudonym -> Map.entry(original, pseudonym)), + concurrency) + .collectMap(Map.Entry::getKey, Map.Entry::getValue); + } + + private static Map createRequest(String namespace, String originalValue) { + return Map.of( + "resourceType", + "Parameters", + "parameter", + List.of(param("namespace", namespace), param("originalValue", originalValue))); + } + + private static Map param(String name, String value) { + return Map.of("name", name, "valueString", value); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java new file mode 100644 index 000000000..d41759e09 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java @@ -0,0 +1,46 @@ +package care.smith.fts.tca.deidentification; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +/** + * Record to deserialize Vfps response from $create-pseudonym operation. + * + *

Response format: + * + *

+ * {
+ *   "resourceType": "Parameters",
+ *   "parameter": [
+ *     {"name": "namespace", "valueString": "domain-name"},
+ *     {"name": "originalValue", "valueString": "patient-123"},
+ *     {"name": "pseudonymValue", "valueString": "generated-pseudonym"}
+ *   ]
+ * }
+ * 
+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record VfpsParameterResponse(String resourceType, List parameter) { + + public String getPseudonymValue() { + return parameter.stream() + .filter(p -> "pseudonymValue".equals(p.name())) + .findFirst() + .map(Parameter::valueString) + .orElseThrow(() -> new IllegalStateException("No pseudonymValue in Vfps response")); + } + + public String getOriginalValue() { + return parameter.stream() + .filter(p -> "originalValue".equals(p.name())) + .findFirst() + .map(Parameter::valueString) + .orElseThrow(() -> new IllegalStateException("No originalValue in Vfps response")); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Parameter(String name, String valueString, ValueIdentifier valueIdentifier) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ValueIdentifier(String system, String value) {} +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java new file mode 100644 index 000000000..c46b3f488 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java @@ -0,0 +1,66 @@ +package care.smith.fts.tca.deidentification.configuration; + +import static care.smith.fts.util.fhir.FhirClientUtils.fetchCapabilityStatementOperations; +import static care.smith.fts.util.fhir.FhirClientUtils.requireOperations; + +import care.smith.fts.util.HttpClientConfig; +import care.smith.fts.util.LogUtil; +import care.smith.fts.util.WebClientFactory; +import java.util.List; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +@ConfigurationProperties(prefix = "de-identification.entici") +@Data +public class EnticiDeIdentificationConfiguration { + + public static final List ENTICI_OPERATIONS = List.of("pseudonymize"); + + private HttpClientConfig fhir; + + /** Maximum number of concurrent requests to Entici for batch processing. */ + private int concurrency = 4; + + /** Resource type to use in pseudonymization requests (default: Patient). */ + private String resourceType = "Patient"; + + /** Optional project name for Entici requests. */ + private String project; + + @Bean("enticiFhirHttpClient") + @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "entici") + public WebClient enticiClient(WebClientFactory clientFactory) { + return clientFactory.create(fhir); + } + + @Bean("enticiApplicationRunner") + @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "entici") + ApplicationRunner runner(@Qualifier("enticiFhirHttpClient") WebClient enticiClient) { + return args -> + fetchCapabilityStatementOperations(enticiClient) + .flatMap(c -> requireOperations(c, ENTICI_OPERATIONS)) + .doOnNext(i -> log.info("Entici available")) + .doOnError(EnticiDeIdentificationConfiguration::logWarning) + .onErrorComplete() + .block(); + } + + private static void logWarning(Throwable e) { + var msg = + """ + Connection to Entici could not be established on agent startup. \ + The agent will continue startup anyway, in case Entici connection will be \ + available later on.\ + """; + LogUtil.warnWithDebugException(log, msg, e); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java new file mode 100644 index 000000000..42eaa7846 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java @@ -0,0 +1,60 @@ +package care.smith.fts.tca.deidentification.configuration; + +import static care.smith.fts.util.fhir.FhirClientUtils.fetchCapabilityStatementOperations; +import static care.smith.fts.util.fhir.FhirClientUtils.requireOperations; + +import care.smith.fts.util.HttpClientConfig; +import care.smith.fts.util.LogUtil; +import care.smith.fts.util.WebClientFactory; +import java.util.List; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Configuration +@ConfigurationProperties(prefix = "de-identification.vfps") +@Data +public class VfpsDeIdentificationConfiguration { + + public static final List VFPS_OPERATIONS = List.of("create-pseudonym"); + + private HttpClientConfig fhir; + + /** Maximum number of concurrent requests to Vfps for batch processing. */ + private int concurrency = 4; + + @Bean("vfpsFhirHttpClient") + @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "vfps") + public WebClient vfpsClient(WebClientFactory clientFactory) { + return clientFactory.create(fhir); + } + + @Bean("vfpsApplicationRunner") + @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "vfps") + ApplicationRunner runner(@Qualifier("vfpsFhirHttpClient") WebClient vfpsClient) { + return args -> + fetchCapabilityStatementOperations(vfpsClient) + .flatMap(c -> requireOperations(c, VFPS_OPERATIONS)) + .doOnNext(i -> log.info("Vfps available")) + .doOnError(VfpsDeIdentificationConfiguration::logWarning) + .onErrorComplete() + .block(); + } + + private static void logWarning(Throwable e) { + var msg = + """ + Connection to Vfps could not be established on agent startup. \ + The agent will continue startup anyway, in case Vfps connection will be \ + available later on.\ + """; + LogUtil.warnWithDebugException(log, msg, e); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java new file mode 100644 index 000000000..c39882c6a --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java @@ -0,0 +1,281 @@ +package care.smith.fts.tca.rest; + +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON_VALUE; + +import care.smith.fts.tca.adapters.PseudonymBackendAdapter; +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeRequest; +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse; +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry; +import care.smith.fts.tca.services.BackendAdapterFactory; +import care.smith.fts.tca.services.TransportIdService; +import care.smith.fts.util.error.ErrorResponseUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import java.util.HashSet; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Base; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity; +import org.hl7.fhir.r4.model.OperationOutcome.IssueType; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.StringType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * REST controller providing Vfps-compatible FHIR endpoint for CDA's FHIR Pseudonymizer. + * + *

This controller exposes a Vfps-compatible {@code $create-pseudonym} endpoint that: + * + *

    + *
  1. Receives pseudonymization requests from CDA's FHIR Pseudonymizer + *
  2. Fetches real pseudonyms (sIDs) from the configured backend (gPAS/Vfps/entici) + *
  3. Generates transport IDs (tIDs) as temporary replacements + *
  4. Stores tID→sID mappings in Redis for later resolution by RDA + *
  5. Returns transport IDs (NOT real pseudonyms) to the FHIR Pseudonymizer + *
+ * + *

This maintains data isolation: clinical data never reaches TCA, only identifiers flow through. + */ +@Slf4j +@RestController +@RequestMapping("/api/v2/cd-agent/fhir") +@Validated +public class CdAgentFhirPseudonymizerController { + + private final TransportIdService transportIdService; + private final PseudonymBackendAdapter backendAdapter; + + public CdAgentFhirPseudonymizerController( + TransportIdService transportIdService, BackendAdapterFactory adapterFactory) { + this.transportIdService = transportIdService; + this.backendAdapter = adapterFactory.createAdapter(); + } + + /** + * Vfps-compatible endpoint to create pseudonyms (returns transport IDs). + * + *

This endpoint mimics Vfps's $create-pseudonym operation but returns transport IDs instead of + * real pseudonyms, maintaining data isolation between domains. + * + * @param requestParams FHIR Parameters with namespace and originalValue(s) + * @return FHIR Parameters with namespace, originalValue, and pseudonymValue (transport ID) + */ + @PostMapping( + value = "/$create-pseudonym", + consumes = APPLICATION_FHIR_JSON_VALUE, + produces = APPLICATION_FHIR_JSON_VALUE) + @Operation( + summary = "Create pseudonyms (Vfps-compatible, returns transport IDs)", + description = + "Accepts Vfps-format FHIR Parameters with namespace and original values, " + + "returns transport IDs (NOT real pseudonyms) for data isolation.\n\n" + + "The transport IDs can be resolved to real pseudonyms via the RDA endpoint.", + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = + @Content( + mediaType = APPLICATION_FHIR_JSON_VALUE, + schema = @Schema(implementation = Parameters.class), + examples = + @ExampleObject( + value = + """ + { + "resourceType": "Parameters", + "parameter": [ + {"name": "namespace", "valueString": "clinical-domain"}, + {"name": "originalValue", "valueString": "patient-123"} + ] + } + """))), + responses = { + @ApiResponse( + responseCode = "200", + description = "Transport IDs generated successfully", + content = + @Content( + mediaType = APPLICATION_FHIR_JSON_VALUE, + schema = @Schema(implementation = Parameters.class), + examples = + @ExampleObject( + value = + """ + { + "resourceType": "Parameters", + "parameter": [ + {"name": "namespace", "valueString": "clinical-domain"}, + {"name": "originalValue", "valueString": "patient-123"}, + {"name": "pseudonymValue", "valueString": "tID-abc123xyz..."} + ] + } + """))), + @ApiResponse( + responseCode = "400", + description = "Invalid request (missing namespace or originalValue)", + content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)), + @ApiResponse( + responseCode = "502", + description = "Backend service unavailable", + content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)) + }) + public Mono> createPseudonym( + @Valid @RequestBody Parameters requestParams) { + + log.debug("Received Vfps $create-pseudonym request from CDA"); + + return Mono.fromCallable(() -> parseRequest(requestParams)) + .flatMap(this::processRequest) + .map(this::buildResponse) + .map(ResponseEntity::ok) + .onErrorResume(this::handleError); + } + + private VfpsPseudonymizeRequest parseRequest(Parameters params) { + // Extract namespace + String namespace = + params.getParameter().stream() + .filter(p -> "namespace".equals(p.getName())) + .findFirst() + .map(ParametersParameterComponent::getValue) + .map(Base::primitiveValue) + .orElseThrow( + () -> new IllegalArgumentException("Missing required parameter 'namespace'")); + + if (namespace.isBlank()) { + throw new IllegalArgumentException("Parameter 'namespace' must not be empty"); + } + + // Extract original values + List originals = + params.getParameter().stream() + .filter(p -> "originalValue".equals(p.getName())) + .map(ParametersParameterComponent::getValue) + .map(Base::primitiveValue) + .toList(); + + if (originals.isEmpty()) { + throw new IllegalArgumentException("At least one 'originalValue' parameter is required"); + } + + // Generate a transfer ID for this batch + var transferId = transportIdService.generateTransferId(); + + log.debug( + "Parsed request: namespace={}, originalCount={}, transferId={}", + namespace, + originals.size(), + transferId); + + return new VfpsPseudonymizeRequest(namespace, originals, transferId); + } + + private Mono processRequest(VfpsPseudonymizeRequest request) { + var transferId = request.transferId(); + var namespace = request.namespace(); + var ttl = transportIdService.getDefaultTtl(); + + log.debug( + "Processing {} identifiers for namespace={}, transferId={}", + request.originals().size(), + namespace, + transferId); + + // Fetch real pseudonyms from backend and generate transport IDs + return backendAdapter + .fetchOrCreatePseudonyms(namespace, new HashSet<>(request.originals())) + .flatMap( + sIdMap -> + Flux.fromIterable(sIdMap.entrySet()) + .flatMap( + entry -> { + var original = entry.getKey(); + var sId = entry.getValue(); + var tId = transportIdService.generateTransportId(); + + return transportIdService + .storeMapping(transferId, tId, sId, namespace, ttl) + .map(storedTId -> new PseudonymEntry(namespace, original, storedTId)); + }) + .collectList() + .map(VfpsPseudonymizeResponse::new)) + .doOnSuccess( + response -> + log.debug( + "Generated {} transport IDs for transferId={}", + response.pseudonyms().size(), + transferId)); + } + + private Parameters buildResponse(VfpsPseudonymizeResponse response) { + var fhirParams = new Parameters(); + + for (var entry : response.pseudonyms()) { + // For single-value responses, use flat structure + if (response.pseudonyms().size() == 1) { + fhirParams.addParameter().setName("namespace").setValue(new StringType(entry.namespace())); + fhirParams + .addParameter() + .setName("originalValue") + .setValue(new StringType(entry.original())); + fhirParams + .addParameter() + .setName("pseudonymValue") + .setValue(new StringType(entry.pseudonym())); + } else { + // For batch responses, use nested structure + var pseudonymParam = new ParametersParameterComponent(); + pseudonymParam.setName("pseudonym"); + + pseudonymParam.addPart().setName("namespace").setValue(new StringType(entry.namespace())); + pseudonymParam + .addPart() + .setName("originalValue") + .setValue(new StringType(entry.original())); + pseudonymParam + .addPart() + .setName("pseudonymValue") + .setValue(new StringType(entry.pseudonym())); + + fhirParams.addParameter(pseudonymParam); + } + } + + log.trace("Built FHIR Parameters response with {} entries", response.pseudonyms().size()); + return fhirParams; + } + + private Mono> handleError(Throwable error) { + log.warn("Error processing $create-pseudonym request: {}", error.getMessage()); + + if (error instanceof IllegalArgumentException) { + return Mono.just( + ResponseEntity.badRequest() + .body(buildOperationOutcome(error.getMessage(), IssueType.INVALID))); + } + + return ErrorResponseUtil.internalServerError(error); + } + + private Parameters buildOperationOutcome(String message, IssueType issueType) { + // Return error as FHIR OperationOutcome wrapped in Parameters for protocol compatibility + var outcome = new OperationOutcome(); + outcome.addIssue().setSeverity(IssueSeverity.ERROR).setCode(issueType).setDiagnostics(message); + + var params = new Parameters(); + params.addParameter().setName("outcome").setResource(outcome); + return params; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java new file mode 100644 index 000000000..4c868c36d --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java @@ -0,0 +1,292 @@ +package care.smith.fts.tca.rest; + +import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON_VALUE; + +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse; +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry; +import care.smith.fts.tca.services.TransportIdService; +import care.smith.fts.util.error.ErrorResponseUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Base; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity; +import org.hl7.fhir.r4.model.OperationOutcome.IssueType; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.StringType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +/** + * REST controller providing Vfps-compatible FHIR endpoint for RDA's FHIR Pseudonymizer. + * + *

This controller exposes a Vfps-compatible {@code $create-pseudonym} endpoint that resolves + * transport IDs (tIDs) to their corresponding secure pseudonyms (sIDs): + * + *

    + *
  1. Receives resolution requests from RDA's FHIR Pseudonymizer + *
  2. Looks up tID→sID mappings in Redis (stored by CDA requests) + *
  3. Returns real pseudonyms (sIDs) to the FHIR Pseudonymizer + *
+ * + *

The RDA endpoint returns actual pseudonyms (unlike CDA endpoint which returns transport IDs), + * completing the data isolation architecture where clinical data flows CDA→RDA but identifiers are + * resolved via TCA. + */ +@Slf4j +@RestController +@RequestMapping("/api/v2/rd-agent/fhir") +@Validated +public class RdAgentFhirPseudonymizerController { + + private final TransportIdService transportIdService; + + public RdAgentFhirPseudonymizerController(TransportIdService transportIdService) { + this.transportIdService = transportIdService; + } + + /** + * Vfps-compatible endpoint to resolve transport IDs to secure pseudonyms. + * + *

This endpoint accepts transport IDs (returned by CDA endpoint) and resolves them to their + * corresponding secure pseudonyms (sIDs) stored in Redis. + * + * @param requestParams FHIR Parameters with namespace and originalValue(s) containing transport + * IDs + * @return FHIR Parameters with namespace, originalValue, and pseudonymValue (real sID) + */ + @PostMapping( + value = "/$create-pseudonym", + consumes = APPLICATION_FHIR_JSON_VALUE, + produces = APPLICATION_FHIR_JSON_VALUE) + @Operation( + summary = "Resolve transport IDs to secure pseudonyms (Vfps-compatible)", + description = + "Accepts Vfps-format FHIR Parameters with transport IDs, " + + "returns the corresponding secure pseudonyms (sIDs) from Redis.\n\n" + + "This endpoint is used by RDA's FHIR Pseudonymizer to resolve transport IDs " + + "received from CDA bundles to their final pseudonyms.", + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = + @Content( + mediaType = APPLICATION_FHIR_JSON_VALUE, + schema = @Schema(implementation = Parameters.class), + examples = + @ExampleObject( + value = + """ + { + "resourceType": "Parameters", + "parameter": [ + {"name": "namespace", "valueString": "clinical-domain"}, + {"name": "originalValue", "valueString": "tID-abc123xyz..."} + ] + } + """))), + responses = { + @ApiResponse( + responseCode = "200", + description = "Secure pseudonyms resolved successfully", + content = + @Content( + mediaType = APPLICATION_FHIR_JSON_VALUE, + schema = @Schema(implementation = Parameters.class), + examples = + @ExampleObject( + value = + """ + { + "resourceType": "Parameters", + "parameter": [ + {"name": "namespace", "valueString": "clinical-domain"}, + {"name": "originalValue", "valueString": "tID-abc123xyz..."}, + {"name": "pseudonymValue", "valueString": "sID-real-pseudonym"} + ] + } + """))), + @ApiResponse( + responseCode = "400", + description = "Invalid request (missing namespace or originalValue)", + content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)), + @ApiResponse( + responseCode = "404", + description = "Transport ID not found (may have expired)", + content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)) + }) + public Mono> resolvePseudonyms( + @Valid @RequestBody Parameters requestParams) { + + log.debug("Received Vfps $create-pseudonym request from RDA"); + + return Mono.fromCallable(() -> parseRequest(requestParams)) + .flatMap(this::resolveTransportIds) + .map(this::buildResponse) + .map(ResponseEntity::ok) + .onErrorResume(this::handleError); + } + + private record ResolutionRequest( + String namespace, List transportIds, String transferId) {} + + private ResolutionRequest parseRequest(Parameters params) { + // Extract namespace + String namespace = + params.getParameter().stream() + .filter(p -> "namespace".equals(p.getName())) + .findFirst() + .map(ParametersParameterComponent::getValue) + .map(Base::primitiveValue) + .orElseThrow( + () -> new IllegalArgumentException("Missing required parameter 'namespace'")); + + if (namespace.isBlank()) { + throw new IllegalArgumentException("Parameter 'namespace' must not be empty"); + } + + // Extract transport IDs (originalValue parameters contain tIDs to resolve) + List transportIds = + params.getParameter().stream() + .filter(p -> "originalValue".equals(p.getName())) + .map(ParametersParameterComponent::getValue) + .map(Base::primitiveValue) + .toList(); + + if (transportIds.isEmpty()) { + throw new IllegalArgumentException("At least one 'originalValue' parameter is required"); + } + + // Extract transferId if provided (for scoped lookups) + String transferId = + params.getParameter().stream() + .filter(p -> "transferId".equals(p.getName())) + .findFirst() + .map(ParametersParameterComponent::getValue) + .map(Base::primitiveValue) + .orElse(null); + + log.debug( + "Parsed RDA request: namespace={}, transportIdCount={}, transferId={}", + namespace, + transportIds.size(), + transferId); + + return new ResolutionRequest(namespace, transportIds, transferId); + } + + private Mono resolveTransportIds(ResolutionRequest request) { + var namespace = request.namespace(); + var transportIds = new HashSet<>(request.transportIds()); + var transferId = request.transferId(); + + log.debug( + "Resolving {} transport IDs for namespace={}, transferId={}", + transportIds.size(), + namespace, + transferId); + + if (transferId == null) { + // Without transferId, we can't resolve (need to know which session the tIDs belong to) + return Mono.error( + new IllegalArgumentException("Parameter 'transferId' is required for RDA resolution")); + } + + return transportIdService + .resolveMappings(transferId, transportIds) + .map( + resolvedMappings -> { + List entries = new ArrayList<>(); + for (var transportId : request.transportIds()) { + var sId = resolvedMappings.get(transportId); + if (sId != null) { + entries.add(new PseudonymEntry(namespace, transportId, sId)); + } else { + log.warn( + "Transport ID not found: tId={}, transferId={}", transportId, transferId); + // Return the tID as-is if not found (or could throw error) + entries.add(new PseudonymEntry(namespace, transportId, transportId)); + } + } + return new VfpsPseudonymizeResponse(entries); + }) + .doOnSuccess( + response -> + log.debug( + "Resolved {} transport IDs for transferId={}", + response.pseudonyms().size(), + transferId)); + } + + private Parameters buildResponse(VfpsPseudonymizeResponse response) { + var fhirParams = new Parameters(); + + for (var entry : response.pseudonyms()) { + // For single-value responses, use flat structure + if (response.pseudonyms().size() == 1) { + fhirParams.addParameter().setName("namespace").setValue(new StringType(entry.namespace())); + fhirParams + .addParameter() + .setName("originalValue") + .setValue(new StringType(entry.original())); + fhirParams + .addParameter() + .setName("pseudonymValue") + .setValue(new StringType(entry.pseudonym())); + } else { + // For batch responses, use nested structure + var pseudonymParam = new ParametersParameterComponent(); + pseudonymParam.setName("pseudonym"); + + pseudonymParam.addPart().setName("namespace").setValue(new StringType(entry.namespace())); + pseudonymParam + .addPart() + .setName("originalValue") + .setValue(new StringType(entry.original())); + pseudonymParam + .addPart() + .setName("pseudonymValue") + .setValue(new StringType(entry.pseudonym())); + + fhirParams.addParameter(pseudonymParam); + } + } + + log.trace("Built FHIR Parameters response with {} entries", response.pseudonyms().size()); + return fhirParams; + } + + private Mono> handleError(Throwable error) { + log.warn("Error processing RDA $create-pseudonym request: {}", error.getMessage()); + + if (error instanceof IllegalArgumentException) { + return Mono.just( + ResponseEntity.badRequest() + .body(buildOperationOutcome(error.getMessage(), IssueType.INVALID))); + } + + return ErrorResponseUtil.internalServerError(error); + } + + private Parameters buildOperationOutcome(String message, IssueType issueType) { + var outcome = new OperationOutcome(); + outcome.addIssue().setSeverity(IssueSeverity.ERROR).setCode(issueType).setDiagnostics(message); + + var params = new Parameters(); + params.addParameter().setName("outcome").setResource(outcome); + return params; + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java new file mode 100644 index 000000000..382884685 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java @@ -0,0 +1,34 @@ +package care.smith.fts.tca.rest.dto; + +import java.util.List; +import java.util.Objects; + +/** + * Vfps-compatible pseudonymization request parsed from FHIR Parameters. + * + *

This DTO represents the internal representation of a Vfps $create-pseudonym request after + * parsing the FHIR Parameters resource. The original request contains: + * + *

    + *
  • namespace - The domain/namespace for pseudonym generation + *
  • originalValue - One or more original values to pseudonymize + *
+ * + * @param namespace The domain/namespace for pseudonym generation (non-blank) + * @param originals The list of original values to pseudonymize (at least one) + * @param transferId The transfer session identifier for grouping mappings + */ +public record VfpsPseudonymizeRequest(String namespace, List originals, String transferId) { + + public VfpsPseudonymizeRequest { + Objects.requireNonNull(namespace, "namespace is required"); + if (namespace.isBlank()) { + throw new IllegalArgumentException("namespace must not be blank"); + } + if (originals == null || originals.isEmpty()) { + throw new IllegalArgumentException("at least one original value required"); + } + Objects.requireNonNull(transferId, "transferId is required"); + originals = List.copyOf(originals); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java new file mode 100644 index 000000000..4d7e8a854 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java @@ -0,0 +1,33 @@ +package care.smith.fts.tca.rest.dto; + +import java.util.List; + +/** + * Vfps-compatible pseudonymization response to be converted to FHIR Parameters. + * + *

This DTO represents the internal representation of a Vfps $create-pseudonym response before + * serialization to FHIR Parameters. The response contains pseudonym entries with: + * + *

    + *
  • namespace - The original namespace from the request + *
  • originalValue - The original value that was pseudonymized + *
  • pseudonymValue - The generated pseudonym (transport ID for CDA, real sID for RDA) + *
+ * + * @param pseudonyms List of pseudonym entries + */ +public record VfpsPseudonymizeResponse(List pseudonyms) { + + public VfpsPseudonymizeResponse { + pseudonyms = pseudonyms != null ? List.copyOf(pseudonyms) : List.of(); + } + + /** + * A single pseudonym mapping entry. + * + * @param namespace The domain/namespace + * @param original The original value + * @param pseudonym The generated pseudonym (tID or sID depending on endpoint) + */ + public record PseudonymEntry(String namespace, String original, String pseudonym) {} +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java new file mode 100644 index 000000000..7e5b5af58 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java @@ -0,0 +1,106 @@ +package care.smith.fts.tca.services; + +import care.smith.fts.tca.adapters.EnticiBackendAdapter; +import care.smith.fts.tca.adapters.GpasBackendAdapter; +import care.smith.fts.tca.adapters.PseudonymBackendAdapter; +import care.smith.fts.tca.adapters.VfpsBackendAdapter; +import care.smith.fts.tca.config.BackendAdapterConfig; +import care.smith.fts.tca.config.BackendAdapterConfig.BackendType; +import care.smith.fts.tca.deidentification.EnticiClient; +import care.smith.fts.tca.deidentification.GpasClient; +import care.smith.fts.tca.deidentification.VfpsClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Factory for creating pseudonymization backend adapters. + * + *

This factory creates the appropriate {@link PseudonymBackendAdapter} based on the configured + * backend type. Currently supports: + * + *

    + *
  • gPAS - Uses existing {@link GpasClient} implementation + *
  • Vfps - Uses {@link VfpsClient} for Very Fast Pseudonym Service + *
  • entici - Uses {@link EnticiClient} for Entici pseudonymization service + *
+ * + *

The factory is configured via {@link BackendAdapterConfig} which reads from application.yaml: + * + *

+ * de-identification:
+ *   backend:
+ *     type: gpas  # or: vfps, entici
+ * 
+ */ +@Slf4j +@Component +public class BackendAdapterFactory { + + private final BackendAdapterConfig config; + private final GpasClient gpasClient; + private final VfpsClient vfpsClient; + private final EnticiClient enticiClient; + + public BackendAdapterFactory( + BackendAdapterConfig config, + GpasClient gpasClient, + @Autowired(required = false) VfpsClient vfpsClient, + @Autowired(required = false) EnticiClient enticiClient) { + this.config = config; + this.gpasClient = gpasClient; + this.vfpsClient = vfpsClient; + this.enticiClient = enticiClient; + } + + /** + * Creates a backend adapter based on the configured type. + * + * @return the configured PseudonymBackendAdapter + * @throws IllegalStateException if the required client is not configured + */ + public PseudonymBackendAdapter createAdapter() { + var type = config.getType(); + log.info("Creating backend adapter for type: {}", type); + + return switch (type) { + case GPAS -> createGpasAdapter(); + case VFPS -> createVfpsAdapter(); + case ENTICI -> createEnticiAdapter(); + }; + } + + private PseudonymBackendAdapter createGpasAdapter() { + log.debug("Initializing gPAS backend adapter"); + return new GpasBackendAdapter(gpasClient); + } + + private PseudonymBackendAdapter createVfpsAdapter() { + if (vfpsClient == null) { + throw new IllegalStateException( + "Vfps backend selected but VfpsClient is not configured. " + + "Add de-identification.vfps.fhir.base-url to configuration."); + } + log.debug("Initializing Vfps backend adapter"); + return new VfpsBackendAdapter(vfpsClient); + } + + private PseudonymBackendAdapter createEnticiAdapter() { + if (enticiClient == null) { + throw new IllegalStateException( + "Entici backend selected but EnticiClient is not configured. " + + "Add de-identification.entici.fhir.base-url to configuration."); + } + log.debug("Initializing Entici backend adapter"); + return new EnticiBackendAdapter(enticiClient); + } + + /** + * Gets the currently configured backend type. + * + * @return the backend type + */ + public BackendType getConfiguredBackendType() { + return config.getType(); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java new file mode 100644 index 000000000..91824f56e --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java @@ -0,0 +1,25 @@ +package care.smith.fts.tca.services; + +import java.util.Objects; + +/** + * Mapping between a transport ID and its corresponding secure pseudonym. + * + *

This record represents a single tID→sID mapping stored in Redis for later resolution by the + * Research Domain Agent. + * + * @param transportId The transport ID (tID) - temporary identifier returned to CDA + * @param securePseudonym The real pseudonym (sID) from the backend (gPAS/Vfps/entici) + * @param domain The pseudonymization domain/namespace + * @param transferId The session grouping identifier + */ +public record TransportIdMapping( + String transportId, String securePseudonym, String domain, String transferId) { + + public TransportIdMapping { + Objects.requireNonNull(transportId, "transportId is required"); + Objects.requireNonNull(securePseudonym, "securePseudonym is required"); + Objects.requireNonNull(domain, "domain is required"); + Objects.requireNonNull(transferId, "transferId is required"); + } +} diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java new file mode 100644 index 000000000..b0f51ae88 --- /dev/null +++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java @@ -0,0 +1,244 @@ +package care.smith.fts.tca.services; + +import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy; + +import care.smith.fts.tca.deidentification.configuration.TransportMappingConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RMapCacheReactive; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Service for generating and managing transport IDs. + * + *

Transport IDs (tIDs) are temporary identifiers used to replace real pseudonyms during data + * transfer from CDA to RDA. The tID→sID mappings are stored in Redis with a configurable TTL. + * + *

Key responsibilities: + * + *

    + *
  • Generate cryptographically secure transport IDs (32 chars, Base64URL) + *
  • Store tID→sID mappings in Redis grouped by transfer session + *
  • Resolve transport IDs back to secure pseudonyms for RDA + *
  • Manage date shift values per transfer session + *
+ */ +@Slf4j +@Service +public class TransportIdService { + + private static final int ID_BYTES = 24; // 24 bytes = 32 Base64URL chars + private static final String DATE_SHIFT_KEY = "_dateShiftMillis"; + private static final String KEY_PREFIX = "transport-mapping:"; + + private final SecureRandom secureRandom; + private final RedissonClient redisClient; + private final Duration defaultTtl; + private final MeterRegistry meterRegistry; + + public TransportIdService( + RedissonClient redisClient, + TransportMappingConfiguration config, + MeterRegistry meterRegistry) { + this.secureRandom = new SecureRandom(); + this.redisClient = redisClient; + this.defaultTtl = config.getTtl(); + this.meterRegistry = meterRegistry; + } + + /** + * Generates a cryptographically secure transport ID. + * + * @return a 32-character Base64URL-encoded transport ID + */ + public String generateTransportId() { + byte[] bytes = new byte[ID_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Generates a new transfer session ID. + * + * @return a unique transfer session identifier + */ + public String generateTransferId() { + return generateTransportId(); + } + + /** + * Stores a single tID→sID mapping in Redis. + * + * @param transferId the transfer session identifier + * @param transportId the transport ID (tID) + * @param securePseudonym the real pseudonym (sID) + * @param domain the pseudonymization domain + * @param ttl time-to-live for this mapping + * @return Mono emitting the stored transport ID + */ + public Mono storeMapping( + String transferId, String transportId, String securePseudonym, String domain, Duration ttl) { + var mapCache = getMapCache(transferId); + return mapCache + .fastPut(transportId, securePseudonym, ttl.toMillis(), TimeUnit.MILLISECONDS) + .retryWhen(defaultRetryStrategy(meterRegistry, "storeTransportMapping")) + .doOnSuccess( + v -> + log.trace( + "Stored mapping: transferId={}, tID={} (domain={})", + transferId, + transportId, + domain)) + .thenReturn(transportId); + } + + /** + * Stores multiple tID→sID mappings in Redis. + * + * @param transferId the transfer session identifier + * @param mappings map from transport ID to secure pseudonym + * @param domain the pseudonymization domain + * @param ttl time-to-live for these mappings + * @return Mono emitting the stored mappings + */ + public Mono> storeMappings( + String transferId, Map mappings, String domain, Duration ttl) { + if (mappings.isEmpty()) { + return Mono.just(Map.of()); + } + + var mapCache = getMapCache(transferId); + return Flux.fromIterable(mappings.entrySet()) + .flatMap( + entry -> + mapCache + .fastPut( + entry.getKey(), entry.getValue(), ttl.toMillis(), TimeUnit.MILLISECONDS) + .thenReturn(entry)) + .collectMap(Map.Entry::getKey, Map.Entry::getValue) + .retryWhen(defaultRetryStrategy(meterRegistry, "storeTransportMappings")) + .doOnSuccess( + m -> + log.trace( + "Stored {} mappings: transferId={} (domain={})", m.size(), transferId, domain)); + } + + /** + * Resolves transport IDs to their corresponding secure pseudonyms. + * + * @param transferId the transfer session identifier + * @param transportIds the set of transport IDs to resolve + * @return Mono emitting a map from tID to sID (only for found mappings) + */ + public Mono> resolveMappings(String transferId, Set transportIds) { + if (transportIds.isEmpty()) { + return Mono.just(Map.of()); + } + + var mapCache = getMapCache(transferId); + return Flux.fromIterable(transportIds) + .flatMap( + tId -> + mapCache + .get(tId) + .map(sId -> Map.entry(tId, sId)) + .defaultIfEmpty(Map.entry(tId, ""))) + .filter(entry -> !entry.getValue().isEmpty()) + .collectMap(Map.Entry::getKey, Map.Entry::getValue) + .retryWhen(defaultRetryStrategy(meterRegistry, "resolveTransportMappings")) + .doOnSuccess( + m -> + log.trace( + "Resolved {} of {} mappings for transferId={}", + m.size(), + transportIds.size(), + transferId)); + } + + /** + * Retrieves all mappings for a transfer session. + * + * @param transferId the transfer session identifier + * @return Mono emitting all tID→sID mappings for this session + */ + public Mono> getAllMappings(String transferId) { + var mapCache = getMapCache(transferId); + return mapCache + .readAllMap() + .retryWhen(defaultRetryStrategy(meterRegistry, "getAllTransportMappings")) + .map( + m -> { + var result = new HashMap<>(m); + result.remove(DATE_SHIFT_KEY); // Exclude metadata + return Map.copyOf(result); + }) + .doOnSuccess( + m -> log.trace("Retrieved {} mappings for transferId={}", m.size(), transferId)); + } + + /** + * Stores the date shift value for a transfer session. + * + * @param transferId the transfer session identifier + * @param dateShiftMillis the date shift value in milliseconds + * @param ttl time-to-live for this value + * @return Mono emitting the stored date shift value + */ + public Mono storeDateShiftValue(String transferId, long dateShiftMillis, Duration ttl) { + var mapCache = getMapCache(transferId); + return mapCache + .fastPut( + DATE_SHIFT_KEY, String.valueOf(dateShiftMillis), ttl.toMillis(), TimeUnit.MILLISECONDS) + .retryWhen(defaultRetryStrategy(meterRegistry, "storeDateShiftValue")) + .doOnSuccess( + v -> + log.trace("Stored dateShift: transferId={}, value={}", transferId, dateShiftMillis)) + .thenReturn(dateShiftMillis); + } + + /** + * Retrieves the date shift value for a transfer session. + * + * @param transferId the transfer session identifier + * @return Mono emitting the date shift value, or empty if not found + */ + public Mono getDateShiftValue(String transferId) { + var mapCache = getMapCache(transferId); + return mapCache + .get(DATE_SHIFT_KEY) + .flatMap( + value -> { + try { + return Mono.just(Long.parseLong(value)); + } catch (NumberFormatException e) { + log.warn("Invalid dateShift value for transferId={}: {}", transferId, value); + return Mono.empty(); + } + }) + .retryWhen(defaultRetryStrategy(meterRegistry, "getDateShiftValue")) + .doOnSuccess(v -> log.trace("Retrieved dateShift: transferId={}, value={}", transferId, v)); + } + + /** + * Gets the default TTL for transport mappings. + * + * @return the default TTL duration + */ + public Duration getDefaultTtl() { + return defaultTtl; + } + + private RMapCacheReactive getMapCache(String transferId) { + return redisClient.reactive().getMapCache(KEY_PREFIX + transferId); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java new file mode 100644 index 000000000..3a986fda7 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java @@ -0,0 +1,81 @@ +package care.smith.fts.tca.adapters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.EnticiClient; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class EnticiBackendAdapterTest { + + @Mock private EnticiClient enticiClient; + + private EnticiBackendAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new EnticiBackendAdapter(enticiClient); + } + + @Test + void getBackendTypeReturnsEntici() { + assertThat(adapter.getBackendType()).isEqualTo("entici"); + } + + @Test + void fetchOrCreatePseudonymDelegatesToEnticiClient() { + var domain = "test-domain"; + var originalValue = "patient-123"; + var expectedPseudonym = "pseudo-456"; + + when(enticiClient.fetchOrCreatePseudonym(domain, originalValue)) + .thenReturn(Mono.just(expectedPseudonym)); + + var result = adapter.fetchOrCreatePseudonym(domain, originalValue); + + StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsDelegatesToEnticiClient() { + var domain = "test-domain"; + var originals = Set.of("patient-1", "patient-2"); + var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2"); + + when(enticiClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected)); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(2); + assertThat(mappings).containsEntry("patient-1", "pseudo-1"); + assertThat(mappings).containsEntry("patient-2", "pseudo-2"); + }) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsHandlesEmptySet() { + var domain = "test-domain"; + var originals = Set.of(); + + when(enticiClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of())); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext(mappings -> assertThat(mappings).isEmpty()) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java new file mode 100644 index 000000000..84545389c --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java @@ -0,0 +1,81 @@ +package care.smith.fts.tca.adapters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.GpasClient; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class GpasBackendAdapterTest { + + @Mock private GpasClient gpasClient; + + private GpasBackendAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new GpasBackendAdapter(gpasClient); + } + + @Test + void getBackendTypeReturnsGpas() { + assertThat(adapter.getBackendType()).isEqualTo("gpas"); + } + + @Test + void fetchOrCreatePseudonymDelegatesToGpasClient() { + var domain = "test-domain"; + var originalValue = "patient-123"; + var expectedPseudonym = "pseudo-456"; + + when(gpasClient.fetchOrCreatePseudonyms(domain, Set.of(originalValue))) + .thenReturn(Mono.just(Map.of(originalValue, expectedPseudonym))); + + var result = adapter.fetchOrCreatePseudonym(domain, originalValue); + + StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsDelegatesToGpasClient() { + var domain = "test-domain"; + var originals = Set.of("patient-1", "patient-2"); + var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2"); + + when(gpasClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected)); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(2); + assertThat(mappings).containsEntry("patient-1", "pseudo-1"); + assertThat(mappings).containsEntry("patient-2", "pseudo-2"); + }) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsHandlesEmptySet() { + var domain = "test-domain"; + var originals = Set.of(); + + when(gpasClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of())); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext(mappings -> assertThat(mappings).isEmpty()) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java new file mode 100644 index 000000000..36730d078 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java @@ -0,0 +1,81 @@ +package care.smith.fts.tca.adapters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.VfpsClient; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class VfpsBackendAdapterTest { + + @Mock private VfpsClient vfpsClient; + + private VfpsBackendAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new VfpsBackendAdapter(vfpsClient); + } + + @Test + void getBackendTypeReturnsVfps() { + assertThat(adapter.getBackendType()).isEqualTo("vfps"); + } + + @Test + void fetchOrCreatePseudonymDelegatesToVfpsClient() { + var domain = "test-domain"; + var originalValue = "patient-123"; + var expectedPseudonym = "pseudo-456"; + + when(vfpsClient.fetchOrCreatePseudonym(domain, originalValue)) + .thenReturn(Mono.just(expectedPseudonym)); + + var result = adapter.fetchOrCreatePseudonym(domain, originalValue); + + StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsDelegatesToVfpsClient() { + var domain = "test-domain"; + var originals = Set.of("patient-1", "patient-2"); + var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2"); + + when(vfpsClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected)); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(2); + assertThat(mappings).containsEntry("patient-1", "pseudo-1"); + assertThat(mappings).containsEntry("patient-2", "pseudo-2"); + }) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsHandlesEmptySet() { + var domain = "test-domain"; + var originals = Set.of(); + + when(vfpsClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of())); + + var result = adapter.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext(mappings -> assertThat(mappings).isEmpty()) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java new file mode 100644 index 000000000..5286a4cb9 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java @@ -0,0 +1,119 @@ +package care.smith.fts.tca.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import care.smith.fts.tca.config.BackendAdapterConfig.BackendType; +import care.smith.fts.tca.config.BackendAdapterConfig.EnticiConfig; +import care.smith.fts.tca.config.BackendAdapterConfig.GpasConfig; +import care.smith.fts.tca.config.BackendAdapterConfig.VfpsConfig; +import care.smith.fts.util.HttpClientConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class BackendAdapterConfigTest { + + private BackendAdapterConfig config; + + @BeforeEach + void setUp() { + config = new BackendAdapterConfig(); + } + + @Test + void defaultTypeIsGpas() { + assertThat(config.getType()).isEqualTo(BackendType.GPAS); + } + + @Test + void setAndGetType() { + config.setType(BackendType.VFPS); + assertThat(config.getType()).isEqualTo(BackendType.VFPS); + + config.setType(BackendType.ENTICI); + assertThat(config.getType()).isEqualTo(BackendType.ENTICI); + } + + @Test + void getActiveBackendConfigReturnsGpasWhenConfigured() { + var gpasConfig = new GpasConfig(); + gpasConfig.setFhir(new HttpClientConfig("http://gpas:8080")); + config.setGpas(gpasConfig); + config.setType(BackendType.GPAS); + + var active = config.getActiveBackendConfig(); + + assertThat(active).isPresent(); + assertThat(active.get()).isInstanceOf(GpasConfig.class); + } + + @Test + void getActiveBackendConfigReturnsVfpsWhenConfigured() { + var vfpsConfig = new VfpsConfig(); + vfpsConfig.setAddress("dns:///vfps:8081"); + config.setVfps(vfpsConfig); + config.setType(BackendType.VFPS); + + var active = config.getActiveBackendConfig(); + + assertThat(active).isPresent(); + assertThat(active.get()).isInstanceOf(VfpsConfig.class); + } + + @Test + void getActiveBackendConfigReturnsEnticiWhenConfigured() { + var enticiConfig = new EnticiConfig(); + enticiConfig.setBaseUrl("http://entici:8080"); + config.setEntici(enticiConfig); + config.setType(BackendType.ENTICI); + + var active = config.getActiveBackendConfig(); + + assertThat(active).isPresent(); + assertThat(active.get()).isInstanceOf(EnticiConfig.class); + } + + @Test + void getActiveBackendConfigReturnsEmptyWhenNotConfigured() { + config.setType(BackendType.GPAS); + config.setGpas(null); + + var active = config.getActiveBackendConfig(); + + assertThat(active).isEmpty(); + } + + @Test + void gpasConfigCanSetFhir() { + var gpasConfig = new GpasConfig(); + var httpConfig = new HttpClientConfig("http://gpas:8080"); + gpasConfig.setFhir(httpConfig); + + assertThat(gpasConfig.getFhir()).isEqualTo(httpConfig); + } + + @Test + void vfpsConfigCanSetAddressAndAuth() { + var vfpsConfig = new VfpsConfig(); + vfpsConfig.setAddress("dns:///vfps:8081"); + vfpsConfig.setAuth(new HttpClientConfig("http://auth:8080")); + + assertThat(vfpsConfig.getAddress()).isEqualTo("dns:///vfps:8081"); + assertThat(vfpsConfig.getAuth()).isNotNull(); + } + + @Test + void enticiConfigCanSetBaseUrlAndServer() { + var enticiConfig = new EnticiConfig(); + enticiConfig.setBaseUrl("http://entici:8080"); + enticiConfig.setServer(new HttpClientConfig("http://server:8080")); + + assertThat(enticiConfig.getBaseUrl()).isEqualTo("http://entici:8080"); + assertThat(enticiConfig.getServer()).isNotNull(); + } + + @Test + void backendTypeEnumHasAllValues() { + assertThat(BackendType.values()) + .containsExactly(BackendType.GPAS, BackendType.VFPS, BackendType.ENTICI); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java new file mode 100644 index 000000000..6429ec667 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java @@ -0,0 +1,110 @@ +package care.smith.fts.tca.deidentification; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Set.of; +import static org.assertj.core.api.Assertions.assertThat; +import static reactor.test.StepVerifier.create; + +import care.smith.fts.tca.AbstractFhirClientIT; +import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration; +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Map; +import java.util.Set; +import org.hl7.fhir.r4.model.CapabilityStatement; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@SpringBootTest +public class EnticiClientIT + extends AbstractFhirClientIT> { + + @Autowired WebClient.Builder httpClientBuilder; + + @Autowired MeterRegistry meterRegistry; + + @MockitoBean RedissonClient redisClient; // Mock redisClient to allow tests to start + + @BeforeEach + void setUpDependencies() { + init(httpClientBuilder, meterRegistry); + } + + private static final String REQUEST_BODY = + """ + { + "resourceType": "Parameters", + "parameter": [ + { + "name": "identifier", + "valueIdentifier": { + "system": "domain", + "value": "id" + } + }, + { + "name": "resourceType", + "valueString": "Patient" + } + ] + } + """; + + @Override + protected EnticiClient createClient(String baseUrl) { + var config = new EnticiDeIdentificationConfiguration(); + config.setResourceType("Patient"); + return new EnticiClient(httpClientBuilder.baseUrl(baseUrl).build(), meterRegistry, config); + } + + @Override + protected MappingBuilder getRequestMappingBuilder() { + return post(urlPathEqualTo("/$pseudonymize")).withRequestBody(equalToJson(REQUEST_BODY)); + } + + @Override + protected CapabilityStatement getMockCapabilityStatement() { + var capabilities = new CapabilityStatement(); + var rest = capabilities.addRest(); + rest.addOperation().setName("pseudonymize"); + return capabilities; + } + + @Override + protected Mono> executeRequest(String request) { + String[] parts = request.split(":"); + return client.fetchOrCreatePseudonyms(parts[0], of(parts[1])); + } + + @Override + protected String getServerName() { + return "Entici"; + } + + @Override + protected String getDefaultRequest() { + return "domain:id"; + } + + @Override + protected Mono> executeRequestWithClient( + EnticiClient specificClient, String request) { + String[] parts = request.split(":"); + return specificClient.fetchOrCreatePseudonyms(parts[0], of(parts[1])); + } + + @Test + void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() { + create(client.fetchOrCreatePseudonyms("domain", Set.of())) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java new file mode 100644 index 000000000..0567fbbbb --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java @@ -0,0 +1,118 @@ +package care.smith.fts.tca.deidentification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; +import org.springframework.web.reactive.function.client.WebClient.RequestBodyUriSpec; +import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec; +import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class EnticiClientTest { + + @Mock private WebClient webClient; + @Mock private RequestBodyUriSpec requestBodyUriSpec; + @Mock private RequestBodySpec requestBodySpec; + @Mock private RequestHeadersSpec requestHeadersSpec; + @Mock private ResponseSpec responseSpec; + + private MeterRegistry meterRegistry; + private EnticiDeIdentificationConfiguration config; + private EnticiClient client; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + config = new EnticiDeIdentificationConfiguration(); + config.setConcurrency(4); + config.setResourceType("Patient"); + client = new EnticiClient(webClient, meterRegistry, config); + } + + @Test + void fetchOrCreatePseudonymReturnsPseudonymValue() { + var domain = "http://test-domain"; + var originalValue = "patient-123"; + var expectedPseudonym = "pseudo-abc"; + + var response = + new EnticiParameterResponse( + "Parameters", + java.util.List.of( + new EnticiParameterResponse.Parameter( + "pseudonym", + null, + new EnticiParameterResponse.ValueIdentifier(domain, expectedPseudonym)))); + + setupWebClientMock(response); + + var result = client.fetchOrCreatePseudonym(domain, originalValue); + + StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsReturnsMappings() { + var domain = "http://test-domain"; + var originals = Set.of("patient-1", "patient-2"); + + var response = + new EnticiParameterResponse( + "Parameters", + java.util.List.of( + new EnticiParameterResponse.Parameter( + "pseudonym", + null, + new EnticiParameterResponse.ValueIdentifier(domain, "pseudo-value")))); + + setupWebClientMock(response); + + var result = client.fetchOrCreatePseudonyms(domain, originals); + + StepVerifier.create(result) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(2); + assertThat(mappings).containsKey("patient-1"); + assertThat(mappings).containsKey("patient-2"); + assertThat(mappings.get("patient-1")).isEqualTo("pseudo-value"); + assertThat(mappings.get("patient-2")).isEqualTo("pseudo-value"); + }) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() { + var result = client.fetchOrCreatePseudonyms("domain", Set.of()); + + StepVerifier.create(result) + .assertNext(mappings -> assertThat(mappings).isEmpty()) + .verifyComplete(); + } + + @SuppressWarnings("unchecked") + private void setupWebClientMock(EnticiParameterResponse response) { + when(webClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(any(String.class))).thenReturn(requestBodySpec); + when(requestBodySpec.headers(any())).thenReturn(requestBodySpec); + when(requestBodySpec.bodyValue(any())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.headers(any())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.onStatus(any(), any())).thenReturn(responseSpec); + when(responseSpec.bodyToMono(EnticiParameterResponse.class)).thenReturn(Mono.just(response)); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java new file mode 100644 index 000000000..43474a8d1 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java @@ -0,0 +1,99 @@ +package care.smith.fts.tca.deidentification; + +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Set.of; +import static org.assertj.core.api.Assertions.assertThat; +import static reactor.test.StepVerifier.create; + +import care.smith.fts.tca.AbstractFhirClientIT; +import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration; +import com.github.tomakehurst.wiremock.client.MappingBuilder; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Map; +import java.util.Set; +import org.hl7.fhir.r4.model.CapabilityStatement; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@SpringBootTest +public class VfpsClientIT extends AbstractFhirClientIT> { + + @Autowired WebClient.Builder httpClientBuilder; + + @Autowired MeterRegistry meterRegistry; + + @MockitoBean RedissonClient redisClient; // Mock redisClient to allow tests to start + + @BeforeEach + void setUpDependencies() { + init(httpClientBuilder, meterRegistry); + } + + private static final String REQUEST_BODY = + """ + { + "resourceType": "Parameters", + "parameter": [ + {"name": "namespace", "valueString": "domain"}, + {"name": "originalValue", "valueString": "id"} + ] + } + """; + + @Override + protected VfpsClient createClient(String baseUrl) { + var config = new VfpsDeIdentificationConfiguration(); + return new VfpsClient(httpClientBuilder.baseUrl(baseUrl).build(), meterRegistry, config); + } + + @Override + protected MappingBuilder getRequestMappingBuilder() { + return post(urlPathEqualTo("/$create-pseudonym")).withRequestBody(equalToJson(REQUEST_BODY)); + } + + @Override + protected CapabilityStatement getMockCapabilityStatement() { + var capabilities = new CapabilityStatement(); + var rest = capabilities.addRest(); + rest.addOperation().setName("create-pseudonym"); + return capabilities; + } + + @Override + protected Mono> executeRequest(String request) { + String[] parts = request.split(":"); + return client.fetchOrCreatePseudonyms(parts[0], of(parts[1])); + } + + @Override + protected String getServerName() { + return "Vfps"; + } + + @Override + protected String getDefaultRequest() { + return "domain:id"; + } + + @Override + protected Mono> executeRequestWithClient( + VfpsClient specificClient, String request) { + String[] parts = request.split(":"); + return specificClient.fetchOrCreatePseudonyms(parts[0], of(parts[1])); + } + + @Test + void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() { + create(client.fetchOrCreatePseudonyms("domain", Set.of())) + .assertNext(result -> assertThat(result).isEmpty()) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java new file mode 100644 index 000000000..04e9b7bd6 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java @@ -0,0 +1,119 @@ +package care.smith.fts.tca.deidentification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec; +import org.springframework.web.reactive.function.client.WebClient.RequestBodyUriSpec; +import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec; +import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class VfpsClientTest { + + @Mock private WebClient webClient; + @Mock private RequestBodyUriSpec requestBodyUriSpec; + @Mock private RequestBodySpec requestBodySpec; + @Mock private RequestHeadersSpec requestHeadersSpec; + @Mock private ResponseSpec responseSpec; + + private MeterRegistry meterRegistry; + private VfpsDeIdentificationConfiguration config; + private VfpsClient client; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + config = new VfpsDeIdentificationConfiguration(); + config.setConcurrency(4); + client = new VfpsClient(webClient, meterRegistry, config); + } + + @Test + void fetchOrCreatePseudonymReturnsPseudonymValue() { + var namespace = "test-namespace"; + var originalValue = "patient-123"; + var expectedPseudonym = "pseudo-abc"; + + var response = + new VfpsParameterResponse( + "Parameters", + java.util.List.of( + new VfpsParameterResponse.Parameter("namespace", namespace, null), + new VfpsParameterResponse.Parameter("originalValue", originalValue, null), + new VfpsParameterResponse.Parameter("pseudonymValue", expectedPseudonym, null))); + + setupWebClientMock(response); + + var result = client.fetchOrCreatePseudonym(namespace, originalValue); + + StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsReturnsMappings() { + var namespace = "test-namespace"; + var originals = Set.of("patient-1", "patient-2"); + + // Use a single response that returns the same pseudonym pattern + // The mapping uses originalValue from request, so we just need consistent pseudonym generation + var response = + new VfpsParameterResponse( + "Parameters", + java.util.List.of( + new VfpsParameterResponse.Parameter("namespace", namespace, null), + new VfpsParameterResponse.Parameter("originalValue", "any-value", null), + new VfpsParameterResponse.Parameter("pseudonymValue", "pseudo-value", null))); + + setupWebClientMock(response); + + var result = client.fetchOrCreatePseudonyms(namespace, originals); + + StepVerifier.create(result) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(2); + // Both entries map to the same pseudonym since we use a single mock response + assertThat(mappings).containsKey("patient-1"); + assertThat(mappings).containsKey("patient-2"); + // Values should be the pseudonym from response + assertThat(mappings.get("patient-1")).isEqualTo("pseudo-value"); + assertThat(mappings.get("patient-2")).isEqualTo("pseudo-value"); + }) + .verifyComplete(); + } + + @Test + void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() { + var result = client.fetchOrCreatePseudonyms("namespace", Set.of()); + + StepVerifier.create(result) + .assertNext(mappings -> assertThat(mappings).isEmpty()) + .verifyComplete(); + } + + @SuppressWarnings("unchecked") + private void setupWebClientMock(VfpsParameterResponse response) { + when(webClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(any(String.class))).thenReturn(requestBodySpec); + when(requestBodySpec.headers(any())).thenReturn(requestBodySpec); + when(requestBodySpec.bodyValue(any())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.headers(any())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.onStatus(any(), any())).thenReturn(responseSpec); + when(responseSpec.bodyToMono(VfpsParameterResponse.class)).thenReturn(Mono.just(response)); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java new file mode 100644 index 000000000..e8caa1aa5 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java @@ -0,0 +1,283 @@ +package care.smith.fts.tca.rest; + +import static care.smith.fts.test.FhirGenerators.fromList; +import static care.smith.fts.test.FhirGenerators.gpasGetOrCreateResponse; +import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON; +import static care.smith.fts.test.MockServerUtil.fhirResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static reactor.test.StepVerifier.create; + +import care.smith.fts.tca.BaseIT; +import care.smith.fts.test.TestWebClientFactory; +import java.io.IOException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * Integration tests for CdAgentFhirPseudonymizerController. + * + *

Tests the Vfps-compatible endpoint that generates transport IDs for CDA requests. + */ +@Slf4j +@SpringBootTest(webEnvironment = RANDOM_PORT) +@Import(TestWebClientFactory.class) +class CdAgentFhirPseudonymizerControllerIT extends BaseIT { + + private static final String VFPS_ENDPOINT = "/api/v2/cd-agent/fhir/$create-pseudonym"; + + @Autowired private RedissonClient redisClient; + private WebClient cdClient; + + @BeforeEach + void setUp(@LocalServerPort int port, @Autowired TestWebClientFactory factory) { + cdClient = factory.webClient("https://localhost:" + port, "cd-agent"); + // Clean up Redis before each test + redisClient.getKeys().deleteByPattern("transport-mapping:*"); + } + + @AfterEach + void tearDown() { + gpas().resetMappings(); + } + + @Test + void createPseudonym_shouldReturnTransportId() throws IOException { + // Setup gPAS mock to return a real pseudonym + var fhirGenerator = + gpasGetOrCreateResponse( + fromList(List.of("patient-123")), fromList(List.of("sID-real-pseudonym-abc"))); + + gpas() + .register( + post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate")) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn(fhirResponse(fhirGenerator.generateString()))); + + // Build Vfps-format request + var requestParams = buildVfpsRequest("clinical-domain", "patient-123"); + + // Send request + var response = + cdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class); + + create(response) + .assertNext( + params -> { + assertThat(params).isNotNull(); + // Single value response has 3 flat parameters: namespace, originalValue, + // pseudonymValue + assertThat(params.getParameter()).hasSize(3); + + // The response should contain a pseudonymValue that is a transport ID (not the real + // sID) + var pseudonymValue = extractPseudonymValue(params); + assertThat(pseudonymValue) + .isNotNull() + .isNotEqualTo("sID-real-pseudonym-abc") // Must NOT be the real pseudonym + .hasSize(32) // Transport IDs are 32 chars (24 bytes Base64URL) + .matches(s -> s.matches("^[A-Za-z0-9_-]+$"), "should be Base64URL encoded"); + }) + .verifyComplete(); + } + + @Test + void createPseudonym_shouldStoreMapping() throws IOException { + // Setup gPAS mock + var fhirGenerator = + gpasGetOrCreateResponse( + fromList(List.of("patient-456")), fromList(List.of("sID-stored-pseudonym"))); + + gpas() + .register( + post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate")) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn(fhirResponse(fhirGenerator.generateString()))); + + // Build and send request + var requestParams = buildVfpsRequest("clinical-domain", "patient-456"); + + var transportId = + cdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class) + .map(this::extractPseudonymValue) + .block(); + + // Verify mapping was stored in Redis + var keys = redisClient.getKeys().getKeysByPattern("transport-mapping:*"); + assertThat(keys).isNotEmpty(); + + // The mapping should contain the transport ID -> real pseudonym + var transferId = keys.iterator().next().replace("transport-mapping:", ""); + var mapping = redisClient.getMapCache("transport-mapping:" + transferId); + assertThat(mapping.get(transportId)).isEqualTo("sID-stored-pseudonym"); + } + + @Test + void createPseudonym_withMultipleOriginals_shouldReturnMultipleTransportIds() throws IOException { + // Setup gPAS mock for batch processing + var fhirGenerator = + gpasGetOrCreateResponse( + fromList(List.of("patient-1", "patient-2", "patient-3")), + fromList(List.of("sID-1", "sID-2", "sID-3"))); + + gpas() + .register( + post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate")) + .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON)) + .willReturn(fhirResponse(fhirGenerator.generateString()))); + + // Build request with multiple originals + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("clinical-domain")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-1")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-2")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-3")); + + var response = + cdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class); + + create(response) + .assertNext( + params -> { + assertThat(params).isNotNull(); + // For batch response (>1 original), should have nested "pseudonym" parameters + // Total parameters should be 3 (one for each original) + var pseudonymParams = + params.getParameter().stream() + .filter(p -> "pseudonym".equals(p.getName())) + .toList(); + + // If we have nested structure, pseudonymParams should have 3 entries + // If not (e.g., flat structure reused), we need to count differently + if (pseudonymParams.isEmpty()) { + // Check if flat structure was used (should not happen for batch) + assertThat(params.getParameter()).hasSizeGreaterThanOrEqualTo(3); + } else { + assertThat(pseudonymParams).hasSize(3); + + // All should have unique transport IDs + var transportIds = + pseudonymParams.stream().map(this::extractPseudonymValueFromPart).toList(); + assertThat(transportIds).hasSize(3).doesNotHaveDuplicates(); + } + }) + .verifyComplete(); + } + + @Test + void createPseudonym_withMissingNamespace_shouldReturn400() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123")); + + var response = + cdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .toBodilessEntity(); + + create(response) + .expectErrorSatisfies( + e -> { + assertThat(e).isInstanceOf(WebClientResponseException.class); + assertThat(((WebClientResponseException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + }) + .verify(); + } + + @Test + void createPseudonym_withMissingOriginalValue_shouldReturn400() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("clinical-domain")); + + var response = + cdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .toBodilessEntity(); + + create(response) + .expectErrorSatisfies( + e -> { + assertThat(e).isInstanceOf(WebClientResponseException.class); + assertThat(((WebClientResponseException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + }) + .verify(); + } + + private Parameters buildVfpsRequest(String namespace, String originalValue) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + params.addParameter().setName("originalValue").setValue(new StringType(originalValue)); + return params; + } + + private String extractPseudonymValue(Parameters params) { + return params.getParameter().stream() + .filter(p -> "pseudonymValue".equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElseGet( + () -> + params.getParameter().stream() + .filter(p -> "pseudonym".equals(p.getName())) + .findFirst() + .map(this::extractPseudonymValueFromPart) + .orElse(null)); + } + + private String extractPseudonymValueFromPart(Parameters.ParametersParameterComponent param) { + return param.getPart().stream() + .filter(p -> "pseudonymValue".equals(p.getName()) || "pseudonym".equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElse(null); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java new file mode 100644 index 000000000..e610bf841 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java @@ -0,0 +1,206 @@ +package care.smith.fts.tca.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.adapters.PseudonymBackendAdapter; +import care.smith.fts.tca.services.BackendAdapterFactory; +import care.smith.fts.tca.services.TransportIdService; +import java.time.Duration; +import java.util.Map; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class CdAgentFhirPseudonymizerControllerTest { + + @Mock private TransportIdService transportIdService; + @Mock private BackendAdapterFactory adapterFactory; + @Mock private PseudonymBackendAdapter backendAdapter; + + private CdAgentFhirPseudonymizerController controller; + + @BeforeEach + void setUp() { + when(adapterFactory.createAdapter()).thenReturn(backendAdapter); + controller = new CdAgentFhirPseudonymizerController(transportIdService, adapterFactory); + } + + @Test + void createPseudonymSuccessfullyReturnsSingleEntry() { + var requestParams = createSingleValueRequest("test-domain", "patient-123"); + var ttl = Duration.ofMinutes(10); + + when(transportIdService.generateTransferId()).thenReturn("transfer-id-1"); + when(transportIdService.getDefaultTtl()).thenReturn(ttl); + when(transportIdService.generateTransportId()).thenReturn("tId-abc123"); + when(transportIdService.storeMapping( + eq("transfer-id-1"), eq("tId-abc123"), eq("sId-456"), eq("test-domain"), eq(ttl))) + .thenReturn(Mono.just("tId-abc123")); + when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet())) + .thenReturn(Mono.just(Map.of("patient-123", "sId-456"))); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var params = response.getBody(); + assertThat(params).isNotNull(); + + var namespace = findParameterValue(params, "namespace"); + var originalValue = findParameterValue(params, "originalValue"); + var pseudonymValue = findParameterValue(params, "pseudonymValue"); + + assertThat(namespace).isEqualTo("test-domain"); + assertThat(originalValue).isEqualTo("patient-123"); + assertThat(pseudonymValue).isEqualTo("tId-abc123"); + }) + .verifyComplete(); + } + + @Test + void createPseudonymSuccessfullyReturnsMultipleEntries() { + var requestParams = createMultiValueRequest("test-domain", "patient-1", "patient-2"); + var ttl = Duration.ofMinutes(10); + + when(transportIdService.generateTransferId()).thenReturn("transfer-id-1"); + when(transportIdService.getDefaultTtl()).thenReturn(ttl); + when(transportIdService.generateTransportId()).thenReturn("tId-1", "tId-2"); + when(transportIdService.storeMapping(anyString(), anyString(), anyString(), anyString(), any())) + .thenAnswer(invocation -> Mono.just(invocation.getArgument(1))); + when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet())) + .thenReturn(Mono.just(Map.of("patient-1", "sId-1", "patient-2", "sId-2"))); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var params = response.getBody(); + assertThat(params).isNotNull(); + assertThat(params.getParameter()).hasSize(2); + + var firstPseudonym = params.getParameter().get(0); + assertThat(firstPseudonym.getName()).isEqualTo("pseudonym"); + }) + .verifyComplete(); + } + + @Test + void createPseudonymReturnsBadRequestForMissingNamespace() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123")); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()) + .contains("Missing required parameter 'namespace'"); + }) + .verifyComplete(); + } + + @Test + void createPseudonymReturnsBadRequestForEmptyNamespace() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType(" ")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123")); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()).contains("must not be empty"); + }) + .verifyComplete(); + } + + @Test + void createPseudonymReturnsBadRequestForMissingOriginalValue() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain")); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()) + .contains("At least one 'originalValue' parameter is required"); + }) + .verifyComplete(); + } + + @Test + void createPseudonymReturnsInternalServerErrorOnBackendFailure() { + var requestParams = createSingleValueRequest("test-domain", "patient-123"); + + when(transportIdService.generateTransferId()).thenReturn("transfer-id-1"); + when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet())) + .thenReturn(Mono.error(new RuntimeException("Backend connection failed"))); + + var result = controller.createPseudonym(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + }) + .verifyComplete(); + } + + private Parameters createSingleValueRequest(String namespace, String originalValue) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + params.addParameter().setName("originalValue").setValue(new StringType(originalValue)); + return params; + } + + private Parameters createMultiValueRequest(String namespace, String... originalValues) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + for (String value : originalValues) { + params.addParameter().setName("originalValue").setValue(new StringType(value)); + } + return params; + } + + private String findParameterValue(Parameters params, String name) { + return params.getParameter().stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElse(null); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java new file mode 100644 index 000000000..3999da67b --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java @@ -0,0 +1,253 @@ +package care.smith.fts.tca.rest; + +import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static reactor.test.StepVerifier.create; + +import care.smith.fts.tca.BaseIT; +import care.smith.fts.tca.services.TransportIdService; +import care.smith.fts.test.TestWebClientFactory; +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * Integration tests for RdAgentFhirPseudonymizerController. + * + *

Tests the Vfps-compatible endpoint that resolves transport IDs to secure pseudonyms (sIDs). + */ +@Slf4j +@SpringBootTest(webEnvironment = RANDOM_PORT) +@Import(TestWebClientFactory.class) +class RdAgentFhirPseudonymizerControllerIT extends BaseIT { + + private static final String VFPS_ENDPOINT = "/api/v2/rd-agent/fhir/$create-pseudonym"; + + @Autowired private RedissonClient redisClient; + @Autowired private TransportIdService transportIdService; + private WebClient rdClient; + + @BeforeEach + void setUp(@LocalServerPort int port, @Autowired TestWebClientFactory factory) { + rdClient = factory.webClient("https://localhost:" + port, "rd-agent"); + // Clean up Redis before each test + redisClient.getKeys().deleteByPattern("transport-mapping:*"); + } + + @AfterEach + void tearDown() { + redisClient.getKeys().deleteByPattern("transport-mapping:*"); + } + + @Test + void resolvePseudonyms_shouldReturnSecurePseudonym() { + // First, store a mapping (simulating what CDA endpoint would do) + var transferId = transportIdService.generateTransferId(); + var tId = "test-transport-id-resolve"; + var sId = "secure-pseudonym-final"; + var domain = "test-domain"; + + transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5)).block(); + + // Build Vfps-format request with the tID + var requestParams = buildVfpsRequest("test-domain", tId, transferId); + + // Send request to resolve + var response = + rdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class); + + create(response) + .assertNext( + params -> { + assertThat(params).isNotNull(); + // Single value response has 3 flat parameters + assertThat(params.getParameter()).hasSize(3); + + // Verify the resolved pseudonym is the sID, not the tID + var pseudonymValue = extractPseudonymValue(params); + assertThat(pseudonymValue) + .isNotNull() + .isEqualTo(sId) // Should be the real secure pseudonym + .isNotEqualTo(tId); // NOT the transport ID + }) + .verifyComplete(); + } + + @Test + void resolvePseudonyms_withUnknownTransportId_shouldReturnOriginal() { + // Create a transfer session without storing mappings + var transferId = transportIdService.generateTransferId(); + var unknownTId = "unknown-transport-id"; + + // Need to store at least something to make the transfer exist + // (Otherwise the request should still work, returning tID as-is) + var requestParams = buildVfpsRequest("test-domain", unknownTId, transferId); + + var response = + rdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class); + + create(response) + .assertNext( + params -> { + assertThat(params).isNotNull(); + // Unknown tID should return the tID itself (not fail) + var pseudonymValue = extractPseudonymValue(params); + assertThat(pseudonymValue).isEqualTo(unknownTId); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonyms_withMissingTransferId_shouldReturn400() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("some-tid")); + // Missing transferId + + var response = + rdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .toBodilessEntity(); + + create(response) + .expectErrorSatisfies( + e -> { + assertThat(e).isInstanceOf(WebClientResponseException.class); + assertThat(((WebClientResponseException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + }) + .verify(); + } + + @Test + void resolvePseudonyms_withMissingNamespace_shouldReturn400() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("originalValue").setValue(new StringType("some-tid")); + requestParams.addParameter().setName("transferId").setValue(new StringType("some-transfer")); + + var response = + rdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .toBodilessEntity(); + + create(response) + .expectErrorSatisfies( + e -> { + assertThat(e).isInstanceOf(WebClientResponseException.class); + assertThat(((WebClientResponseException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + }) + .verify(); + } + + @Test + void resolvePseudonyms_multipleMappings_shouldResolveAll() { + // Store multiple mappings + var transferId = transportIdService.generateTransferId(); + var domain = "test-domain"; + var ttl = Duration.ofMinutes(5); + + transportIdService.storeMapping(transferId, "tId-1", "sId-1", domain, ttl).block(); + transportIdService.storeMapping(transferId, "tId-2", "sId-2", domain, ttl).block(); + transportIdService.storeMapping(transferId, "tId-3", "sId-3", domain, ttl).block(); + + // Build request with multiple tIDs + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType(domain)); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-1")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-2")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-3")); + requestParams.addParameter().setName("transferId").setValue(new StringType(transferId)); + + var response = + rdClient + .post() + .uri(VFPS_ENDPOINT) + .header(CONTENT_TYPE, APPLICATION_FHIR_JSON) + .header("Accept", APPLICATION_FHIR_JSON) + .bodyValue(requestParams) + .retrieve() + .bodyToMono(Parameters.class); + + create(response) + .assertNext( + params -> { + assertThat(params).isNotNull(); + // Should have 3 nested pseudonym entries + var pseudonymParams = + params.getParameter().stream() + .filter(p -> "pseudonym".equals(p.getName())) + .toList(); + assertThat(pseudonymParams).hasSize(3); + }) + .verifyComplete(); + } + + private Parameters buildVfpsRequest(String namespace, String transportId, String transferId) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + params.addParameter().setName("originalValue").setValue(new StringType(transportId)); + params.addParameter().setName("transferId").setValue(new StringType(transferId)); + return params; + } + + private String extractPseudonymValue(Parameters params) { + return params.getParameter().stream() + .filter(p -> "pseudonymValue".equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElseGet( + () -> + params.getParameter().stream() + .filter(p -> "pseudonym".equals(p.getName())) + .findFirst() + .map(this::extractPseudonymValueFromPart) + .orElse(null)); + } + + private String extractPseudonymValueFromPart(Parameters.ParametersParameterComponent param) { + return param.getPart().stream() + .filter(p -> "pseudonymValue".equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElse(null); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java new file mode 100644 index 000000000..64898e10c --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java @@ -0,0 +1,234 @@ +package care.smith.fts.tca.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.services.TransportIdService; +import java.util.Map; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Parameters; +import org.hl7.fhir.r4.model.StringType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class RdAgentFhirPseudonymizerControllerTest { + + @Mock private TransportIdService transportIdService; + + private RdAgentFhirPseudonymizerController controller; + + @BeforeEach + void setUp() { + controller = new RdAgentFhirPseudonymizerController(transportIdService); + } + + @Test + void resolvePseudonymsSuccessfullyReturnsSingleEntry() { + var requestParams = createSingleValueRequest("test-domain", "tId-123", "transfer-id-1"); + + when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet())) + .thenReturn(Mono.just(Map.of("tId-123", "sId-456"))); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var params = response.getBody(); + assertThat(params).isNotNull(); + + var namespace = findParameterValue(params, "namespace"); + var originalValue = findParameterValue(params, "originalValue"); + var pseudonymValue = findParameterValue(params, "pseudonymValue"); + + assertThat(namespace).isEqualTo("test-domain"); + assertThat(originalValue).isEqualTo("tId-123"); + assertThat(pseudonymValue).isEqualTo("sId-456"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsSuccessfullyReturnsMultipleEntries() { + var requestParams = createMultiValueRequest("test-domain", "transfer-id-1", "tId-1", "tId-2"); + + when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet())) + .thenReturn(Mono.just(Map.of("tId-1", "sId-1", "tId-2", "sId-2"))); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var params = response.getBody(); + assertThat(params).isNotNull(); + assertThat(params.getParameter()).hasSize(2); + + var firstPseudonym = params.getParameter().get(0); + assertThat(firstPseudonym.getName()).isEqualTo("pseudonym"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsTidWhenNotFound() { + var requestParams = createSingleValueRequest("test-domain", "tId-missing", "transfer-id-1"); + + when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet())) + .thenReturn(Mono.just(Map.of())); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + var params = response.getBody(); + assertThat(params).isNotNull(); + + var pseudonymValue = findParameterValue(params, "pseudonymValue"); + assertThat(pseudonymValue).isEqualTo("tId-missing"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsBadRequestForMissingNamespace() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123")); + requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1")); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()) + .contains("Missing required parameter 'namespace'"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsBadRequestForEmptyNamespace() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType(" ")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123")); + requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1")); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()).contains("must not be empty"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsBadRequestForMissingOriginalValue() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain")); + requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1")); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()) + .contains("At least one 'originalValue' parameter is required"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsBadRequestForMissingTransferId() { + var requestParams = new Parameters(); + requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain")); + requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123")); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + var params = response.getBody(); + assertThat(params).isNotNull(); + var outcome = (OperationOutcome) params.getParameter().get(0).getResource(); + assertThat(outcome.getIssueFirstRep().getDiagnostics()) + .contains("'transferId' is required"); + }) + .verifyComplete(); + } + + @Test + void resolvePseudonymsReturnsInternalServerErrorOnServiceFailure() { + var requestParams = createSingleValueRequest("test-domain", "tId-123", "transfer-id-1"); + + when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet())) + .thenReturn(Mono.error(new RuntimeException("Redis connection failed"))); + + var result = controller.resolvePseudonyms(requestParams); + + StepVerifier.create(result) + .assertNext( + response -> { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + }) + .verifyComplete(); + } + + private Parameters createSingleValueRequest( + String namespace, String originalValue, String transferId) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + params.addParameter().setName("originalValue").setValue(new StringType(originalValue)); + params.addParameter().setName("transferId").setValue(new StringType(transferId)); + return params; + } + + private Parameters createMultiValueRequest( + String namespace, String transferId, String... originalValues) { + var params = new Parameters(); + params.addParameter().setName("namespace").setValue(new StringType(namespace)); + params.addParameter().setName("transferId").setValue(new StringType(transferId)); + for (String value : originalValues) { + params.addParameter().setName("originalValue").setValue(new StringType(value)); + } + return params; + } + + private String findParameterValue(Parameters params, String name) { + return params.getParameter().stream() + .filter(p -> name.equals(p.getName())) + .findFirst() + .map(p -> p.getValue().primitiveValue()) + .orElse(null); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java new file mode 100644 index 000000000..424d7274e --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java @@ -0,0 +1,72 @@ +package care.smith.fts.tca.rest.dto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class VfpsPseudonymizeRequestTest { + + @Test + void validRequestCreatesImmutableCopy() { + var originals = List.of("original1", "original2"); + var request = new VfpsPseudonymizeRequest("namespace", originals, "transfer-123"); + + assertThat(request.namespace()).isEqualTo("namespace"); + assertThat(request.originals()).containsExactly("original1", "original2"); + assertThat(request.transferId()).isEqualTo("transfer-123"); + } + + @Test + void nullNamespaceThrowsNullPointerException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest(null, List.of("original"), "transfer-123")) + .isInstanceOf(NullPointerException.class) + .hasMessage("namespace is required"); + } + + @Test + void blankNamespaceThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest(" ", List.of("original"), "transfer-123")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } + + @Test + void emptyNamespaceThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest("", List.of("original"), "transfer-123")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } + + @Test + void nullOriginalsThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", null, "transfer-123")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("at least one original value required"); + } + + @Test + void emptyOriginalsThrowsIllegalArgumentException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", List.of(), "transfer-123")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("at least one original value required"); + } + + @Test + void nullTransferIdThrowsNullPointerException() { + assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", List.of("original"), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("transferId is required"); + } + + @Test + void originalsListIsDefensivelyCopied() { + var mutableList = new java.util.ArrayList<>(List.of("original1")); + var request = new VfpsPseudonymizeRequest("namespace", mutableList, "transfer-123"); + + mutableList.add("original2"); + + assertThat(request.originals()).containsExactly("original1"); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java new file mode 100644 index 000000000..8ea20ea21 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java @@ -0,0 +1,58 @@ +package care.smith.fts.tca.rest.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry; +import java.util.List; +import org.junit.jupiter.api.Test; + +class VfpsPseudonymizeResponseTest { + + @Test + void validResponseWithPseudonyms() { + var entries = + List.of( + new PseudonymEntry("namespace", "original1", "pseudo1"), + new PseudonymEntry("namespace", "original2", "pseudo2")); + var response = new VfpsPseudonymizeResponse(entries); + + assertThat(response.pseudonyms()).hasSize(2); + assertThat(response.pseudonyms().get(0).original()).isEqualTo("original1"); + assertThat(response.pseudonyms().get(1).pseudonym()).isEqualTo("pseudo2"); + } + + @Test + void nullPseudonymsCreatesEmptyList() { + var response = new VfpsPseudonymizeResponse(null); + + assertThat(response.pseudonyms()).isNotNull(); + assertThat(response.pseudonyms()).isEmpty(); + } + + @Test + void emptyPseudonymsListIsAllowed() { + var response = new VfpsPseudonymizeResponse(List.of()); + + assertThat(response.pseudonyms()).isEmpty(); + } + + @Test + void pseudonymsListIsDefensivelyCopied() { + var mutableList = + new java.util.ArrayList<>(List.of(new PseudonymEntry("namespace", "original", "pseudo"))); + var response = new VfpsPseudonymizeResponse(mutableList); + + mutableList.add(new PseudonymEntry("namespace", "original2", "pseudo2")); + + assertThat(response.pseudonyms()).hasSize(1); + } + + @Test + void pseudonymEntryRecordStoresValues() { + var entry = new PseudonymEntry("test-namespace", "original-value", "pseudonym-value"); + + assertThat(entry.namespace()).isEqualTo("test-namespace"); + assertThat(entry.original()).isEqualTo("original-value"); + assertThat(entry.pseudonym()).isEqualTo("pseudonym-value"); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java new file mode 100644 index 000000000..7c3eb7173 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java @@ -0,0 +1,99 @@ +package care.smith.fts.tca.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.adapters.EnticiBackendAdapter; +import care.smith.fts.tca.adapters.GpasBackendAdapter; +import care.smith.fts.tca.adapters.VfpsBackendAdapter; +import care.smith.fts.tca.config.BackendAdapterConfig; +import care.smith.fts.tca.config.BackendAdapterConfig.BackendType; +import care.smith.fts.tca.deidentification.EnticiClient; +import care.smith.fts.tca.deidentification.GpasClient; +import care.smith.fts.tca.deidentification.VfpsClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BackendAdapterFactoryTest { + + @Mock private BackendAdapterConfig config; + @Mock private GpasClient gpasClient; + @Mock private VfpsClient vfpsClient; + @Mock private EnticiClient enticiClient; + + private BackendAdapterFactory factory; + + @BeforeEach + void setUp() { + factory = new BackendAdapterFactory(config, gpasClient, vfpsClient, enticiClient); + } + + @Test + void createAdapterReturnsGpasAdapter() { + when(config.getType()).thenReturn(BackendType.GPAS); + + var adapter = factory.createAdapter(); + + assertThat(adapter).isInstanceOf(GpasBackendAdapter.class); + assertThat(adapter.getBackendType()).isEqualTo("gpas"); + } + + @Test + void createAdapterReturnsVfpsAdapter() { + when(config.getType()).thenReturn(BackendType.VFPS); + + var adapter = factory.createAdapter(); + + assertThat(adapter).isInstanceOf(VfpsBackendAdapter.class); + assertThat(adapter.getBackendType()).isEqualTo("vfps"); + } + + @Test + void createAdapterReturnsEnticiAdapter() { + when(config.getType()).thenReturn(BackendType.ENTICI); + + var adapter = factory.createAdapter(); + + assertThat(adapter).isInstanceOf(EnticiBackendAdapter.class); + assertThat(adapter.getBackendType()).isEqualTo("entici"); + } + + @Test + void createAdapterThrowsForVfpsWhenClientNotConfigured() { + var factoryWithoutVfps = new BackendAdapterFactory(config, gpasClient, null, enticiClient); + when(config.getType()).thenReturn(BackendType.VFPS); + + assertThatThrownBy(factoryWithoutVfps::createAdapter) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("VfpsClient is not configured"); + } + + @Test + void createAdapterThrowsForEnticiWhenClientNotConfigured() { + var factoryWithoutEntici = new BackendAdapterFactory(config, gpasClient, vfpsClient, null); + when(config.getType()).thenReturn(BackendType.ENTICI); + + assertThatThrownBy(factoryWithoutEntici::createAdapter) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("EnticiClient is not configured"); + } + + @Test + void getConfiguredBackendTypeReturnsConfiguredType() { + when(config.getType()).thenReturn(BackendType.GPAS); + + assertThat(factory.getConfiguredBackendType()).isEqualTo(BackendType.GPAS); + } + + @Test + void getConfiguredBackendTypeReflectsConfigChanges() { + when(config.getType()).thenReturn(BackendType.VFPS); + + assertThat(factory.getConfiguredBackendType()).isEqualTo(BackendType.VFPS); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java new file mode 100644 index 000000000..ef8e0f5ec --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java @@ -0,0 +1,47 @@ +package care.smith.fts.tca.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class TransportIdMappingTest { + + @Test + void createValidMapping() { + var mapping = new TransportIdMapping("tId-123", "sId-456", "domain", "transfer-1"); + + assertThat(mapping.transportId()).isEqualTo("tId-123"); + assertThat(mapping.securePseudonym()).isEqualTo("sId-456"); + assertThat(mapping.domain()).isEqualTo("domain"); + assertThat(mapping.transferId()).isEqualTo("transfer-1"); + } + + @Test + void nullTransportIdThrowsException() { + assertThatThrownBy(() -> new TransportIdMapping(null, "sId", "domain", "transfer")) + .isInstanceOf(NullPointerException.class) + .hasMessage("transportId is required"); + } + + @Test + void nullSecurePseudonymThrowsException() { + assertThatThrownBy(() -> new TransportIdMapping("tId", null, "domain", "transfer")) + .isInstanceOf(NullPointerException.class) + .hasMessage("securePseudonym is required"); + } + + @Test + void nullDomainThrowsException() { + assertThatThrownBy(() -> new TransportIdMapping("tId", "sId", null, "transfer")) + .isInstanceOf(NullPointerException.class) + .hasMessage("domain is required"); + } + + @Test + void nullTransferIdThrowsException() { + assertThatThrownBy(() -> new TransportIdMapping("tId", "sId", "domain", null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("transferId is required"); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java new file mode 100644 index 000000000..82658642d --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java @@ -0,0 +1,165 @@ +package care.smith.fts.tca.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static reactor.test.StepVerifier.create; + +import care.smith.fts.tca.BaseIT; +import care.smith.fts.test.TestWebClientFactory; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +/** + * Integration tests for TransportIdService. + * + *

Tests transport ID generation, Redis storage, and resolution functionality. + */ +@Slf4j +@SpringBootTest(webEnvironment = RANDOM_PORT) +@Import(TestWebClientFactory.class) +class TransportIdServiceIT extends BaseIT { + + @Autowired private TransportIdService transportIdService; + @Autowired private RedissonClient redisClient; + + @BeforeEach + void setUp() { + // Clean up any existing test data + redisClient.getKeys().deleteByPattern("transport-mapping:*"); + } + + @Test + void generateTransportId_shouldReturnBase64UrlEncodedString() { + var tId = transportIdService.generateTransportId(); + + assertThat(tId) + .isNotNull() + .hasSize(32) + .matches(s -> s.matches("^[A-Za-z0-9_-]+$"), "should be Base64URL encoded"); + } + + @Test + void generateTransportId_shouldBeUnique() { + var id1 = transportIdService.generateTransportId(); + var id2 = transportIdService.generateTransportId(); + var id3 = transportIdService.generateTransportId(); + + assertThat(id1).isNotEqualTo(id2).isNotEqualTo(id3); + assertThat(id2).isNotEqualTo(id3); + } + + @Test + void storeAndRetrieveMapping_shouldWork() { + var transferId = "test-transfer-123"; + var tId = "test-transport-id-abc"; + var sId = "secure-pseudonym-xyz"; + var domain = "test-domain"; + + // Store mapping + var storeMono = + transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5)); + + create(storeMono).expectNext(tId).verifyComplete(); + + // Retrieve mapping + var retrieveMono = transportIdService.resolveMappings(transferId, Set.of(tId)); + + create(retrieveMono) + .assertNext( + mappings -> { + assertThat(mappings).containsEntry(tId, sId); + }) + .verifyComplete(); + } + + @Test + void storeMultipleMappings_shouldWork() { + var transferId = "test-transfer-multi"; + var mappings = + Map.of( + "tId-1", "sId-1", + "tId-2", "sId-2", + "tId-3", "sId-3"); + var domain = "test-domain"; + + // Store all mappings + var storeMono = + transportIdService.storeMappings(transferId, mappings, domain, Duration.ofMinutes(5)); + + create(storeMono) + .assertNext( + stored -> { + assertThat(stored).containsExactlyInAnyOrderEntriesOf(mappings); + }) + .verifyComplete(); + + // Retrieve all mappings + var retrieveMono = transportIdService.resolveMappings(transferId, mappings.keySet()); + + create(retrieveMono) + .assertNext( + resolved -> { + assertThat(resolved).containsExactlyInAnyOrderEntriesOf(mappings); + }) + .verifyComplete(); + } + + @Test + void resolveMappings_withUnknownTransferId_shouldReturnEmpty() { + var retrieveMono = + transportIdService.resolveMappings("non-existent-transfer", Set.of("some-tid")); + + create(retrieveMono).assertNext(mappings -> assertThat(mappings).isEmpty()).verifyComplete(); + } + + @Test + void resolveMappings_withPartialMatch_shouldReturnOnlyKnownMappings() { + var transferId = "test-transfer-partial"; + var tId = "known-tid"; + var sId = "known-sid"; + var domain = "test-domain"; + + // Store one mapping + transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5)).block(); + + // Try to resolve known and unknown tIDs + var retrieveMono = transportIdService.resolveMappings(transferId, Set.of(tId, "unknown-tid")); + + create(retrieveMono) + .assertNext( + mappings -> { + assertThat(mappings).hasSize(1).containsEntry(tId, sId); + }) + .verifyComplete(); + } + + @Test + void storeDateShiftValue_shouldPersistAndRetrieve() { + var transferId = "test-transfer-dateshift"; + var dateShiftMillis = 86400000L; // 1 day + + // Store date shift value + var storeMono = + transportIdService.storeDateShiftValue(transferId, dateShiftMillis, Duration.ofMinutes(5)); + + create(storeMono).expectNext(dateShiftMillis).verifyComplete(); + + // Retrieve date shift value + var retrieveMono = transportIdService.getDateShiftValue(transferId); + + create(retrieveMono) + .assertNext( + value -> { + assertThat(value).isEqualTo(dateShiftMillis); + }) + .verifyComplete(); + } +} diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java new file mode 100644 index 000000000..8929bec25 --- /dev/null +++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java @@ -0,0 +1,216 @@ +package care.smith.fts.tca.services; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import care.smith.fts.tca.deidentification.configuration.TransportMappingConfiguration; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RMapCacheReactive; +import org.redisson.api.RedissonClient; +import org.redisson.api.RedissonReactiveClient; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(MockitoExtension.class) +class TransportIdServiceTest { + + @Mock private RedissonClient redisClient; + @Mock private RedissonReactiveClient reactiveClient; + @Mock private RMapCacheReactive mapCache; + + private TransportIdService service; + private MeterRegistry meterRegistry; + private Duration defaultTtl; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + defaultTtl = Duration.ofMinutes(10); + + var config = new TransportMappingConfiguration(); + config.setTtl(defaultTtl); + + lenient().when(redisClient.reactive()).thenReturn(reactiveClient); + lenient().when(reactiveClient.getMapCache(anyString())).thenReturn(mapCache); + + service = new TransportIdService(redisClient, config, meterRegistry); + } + + @Test + void generateTransportIdReturns32CharBase64() { + var transportId = service.generateTransportId(); + + assertThat(transportId).hasSize(32); + assertThat(transportId).matches("[A-Za-z0-9_-]+"); + } + + @Test + void generateTransferIdReturns32CharBase64() { + var transferId = service.generateTransferId(); + + assertThat(transferId).hasSize(32); + assertThat(transferId).matches("[A-Za-z0-9_-]+"); + } + + @Test + void generatedIdsAreUnique() { + var id1 = service.generateTransportId(); + var id2 = service.generateTransportId(); + var id3 = service.generateTransferId(); + + assertThat(id1).isNotEqualTo(id2); + assertThat(id1).isNotEqualTo(id3); + assertThat(id2).isNotEqualTo(id3); + } + + @Test + void getDefaultTtlReturnsConfiguredValue() { + assertThat(service.getDefaultTtl()).isEqualTo(defaultTtl); + } + + @Test + void storeMappingStoresInRedis() { + when(mapCache.fastPut(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(Mono.just(true)); + + var result = service.storeMapping("transfer-1", "tId-123", "sId-456", "domain", defaultTtl); + + StepVerifier.create(result).expectNext("tId-123").verifyComplete(); + } + + @Test + void storeMappingsWithEmptyMapReturnsEmpty() { + var result = service.storeMappings("transfer-1", Map.of(), "domain", defaultTtl); + + StepVerifier.create(result).expectNext(Map.of()).verifyComplete(); + } + + @Test + void storeMappingsStoresMultipleInRedis() { + when(mapCache.fastPut(anyString(), anyString(), anyLong(), any(TimeUnit.class))) + .thenReturn(Mono.just(true)); + + var mappings = Map.of("tId-1", "sId-1", "tId-2", "sId-2"); + var result = service.storeMappings("transfer-1", mappings, "domain", defaultTtl); + + StepVerifier.create(result) + .assertNext( + stored -> { + assertThat(stored).hasSize(2); + assertThat(stored).containsEntry("tId-1", "sId-1"); + assertThat(stored).containsEntry("tId-2", "sId-2"); + }) + .verifyComplete(); + } + + @Test + void resolveMappingsWithEmptySetReturnsEmpty() { + var result = service.resolveMappings("transfer-1", Set.of()); + + StepVerifier.create(result).expectNext(Map.of()).verifyComplete(); + } + + @Test + void resolveMappingsRetrievesFromRedis() { + when(mapCache.get("tId-1")).thenReturn(Mono.just("sId-1")); + when(mapCache.get("tId-2")).thenReturn(Mono.just("sId-2")); + + var result = service.resolveMappings("transfer-1", Set.of("tId-1", "tId-2")); + + StepVerifier.create(result) + .assertNext( + resolved -> { + assertThat(resolved).hasSize(2); + assertThat(resolved).containsEntry("tId-1", "sId-1"); + assertThat(resolved).containsEntry("tId-2", "sId-2"); + }) + .verifyComplete(); + } + + @Test + void resolveMappingsFiltersNotFound() { + when(mapCache.get("tId-1")).thenReturn(Mono.just("sId-1")); + when(mapCache.get("tId-missing")).thenReturn(Mono.empty()); + + var result = service.resolveMappings("transfer-1", Set.of("tId-1", "tId-missing")); + + StepVerifier.create(result) + .assertNext( + resolved -> { + assertThat(resolved).hasSize(1); + assertThat(resolved).containsEntry("tId-1", "sId-1"); + assertThat(resolved).doesNotContainKey("tId-missing"); + }) + .verifyComplete(); + } + + @Test + void getAllMappingsRetrievesAllFromRedis() { + var allMappings = Map.of("tId-1", "sId-1", "tId-2", "sId-2", "_dateShiftMillis", "1000"); + when(mapCache.readAllMap()).thenReturn(Mono.just(allMappings)); + + var result = service.getAllMappings("transfer-1"); + + StepVerifier.create(result) + .assertNext( + retrieved -> { + assertThat(retrieved).hasSize(2); + assertThat(retrieved).containsEntry("tId-1", "sId-1"); + assertThat(retrieved).containsEntry("tId-2", "sId-2"); + assertThat(retrieved).doesNotContainKey("_dateShiftMillis"); + }) + .verifyComplete(); + } + + @Test + void storeDateShiftValueStoresInRedis() { + when(mapCache.fastPut(eq("_dateShiftMillis"), eq("5000"), anyLong(), any(TimeUnit.class))) + .thenReturn(Mono.just(true)); + + var result = service.storeDateShiftValue("transfer-1", 5000L, defaultTtl); + + StepVerifier.create(result).expectNext(5000L).verifyComplete(); + } + + @Test + void getDateShiftValueRetrievesFromRedis() { + when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.just("3000")); + + var result = service.getDateShiftValue("transfer-1"); + + StepVerifier.create(result).expectNext(3000L).verifyComplete(); + } + + @Test + void getDateShiftValueReturnsEmptyForNotFound() { + when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.empty()); + + var result = service.getDateShiftValue("transfer-1"); + + StepVerifier.create(result).verifyComplete(); + } + + @Test + void getDateShiftValueReturnsEmptyForInvalidNumber() { + when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.just("not-a-number")); + + var result = service.getDateShiftValue("transfer-1"); + + StepVerifier.create(result).verifyComplete(); + } +}