diff --git a/README.md b/README.md index 50aaf4d..1b18729 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ Following environment variables need to be set: * cdrClient.localFolder=~/Documents/cdr/inflight * cdrClient.targetFolder=~/Documents/cdr/target * cdrClient.sourceFolder=~/Documents/cdr/source +* CDR_B2C_TENANT_ID=some-cdr-azure-ad-tenant +* CDR_CLIENT_ID=oauth2-client-id +* CDR_CLIENT_SECRET=oauth2-client-secret ## Application Plugin To create scripts to run the application locally one needs to run following gradle cmd: ```gradlew installDist``` @@ -62,10 +65,8 @@ To run the application locally one can call ```./build/install/cdr-client/bin/cd With a minimum configuration that looks like this: ``` client: - local-folder: /tmp/cdr endpoint: host: cdr.health.swisscom.com - base-path: api/documents customer: - connector-id: 8000000000000 content-type: application/forumdatenaustausch+xml;charset=UTF-8 diff --git a/build.gradle.kts b/build.gradle.kts index 97faf8e..c96385e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,27 +1,30 @@ import io.gitlab.arturbosch.detekt.Detekt import org.springframework.boot.gradle.tasks.bundling.BootJar import java.net.URI +import java.time.Duration group = "com.swisscom.health.des.cdr" version = "3.1.3-SNAPSHOT" java.sourceCompatibility = JavaVersion.VERSION_17 -val jvmVersion: String by project -val kotlinCoroutinesVersion: String by project -val springCloudVersion: String by project +val awaitilityVersion: String by project val jacocoVersion: String by project +val kacheVersion: String by project +val kfsWatchVersion: String by project +val kotlinCoroutinesVersion: String by project val kotlinLoggingVersion: String by project -val mockkVersion: String by project val logstashEncoderVersion: String by project val micrometerTracingVersion: String by project -val kfsWatchVersion: String by project -val kacheVersion: String by project +val mockkVersion: String by project +val msal4jVersion: String by project +val springCloudVersion: String by project val springMockkVersion: String by project -val awaitilityVersion: String by project +val jvmVersion: String by project val outputDir: Provider = layout.buildDirectory.dir(".") plugins { + id("com.avast.gradle.docker-compose") version "0.17.8" id("org.springframework.boot") id("io.spring.dependency-management") id("io.gitlab.arturbosch.detekt") @@ -62,19 +65,21 @@ dependencyManagement { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("com.mayakapps.kache:kache:$kacheVersion") + implementation("com.microsoft.azure:msal4j:$msal4jVersion") implementation("com.squareup.okhttp3:okhttp") + implementation("io.github.irgaly.kfswatch:kfswatch:$kfsWatchVersion") + implementation("io.github.oshai:kotlin-logging:$kotlinLoggingVersion") + implementation("io.micrometer:micrometer-tracing:$micrometerTracingVersion") + implementation("io.micrometer:micrometer-tracing-bridge-otel:$micrometerTracingVersion") + implementation("net.logstash.logback:logstash-logback-encoder:$logstashEncoderVersion") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlinCoroutinesVersion}") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:${kotlinCoroutinesVersion}") // to enable @Scheduled on Kotlin suspending functions - implementation("io.github.oshai:kotlin-logging:${kotlinLoggingVersion}") - implementation("net.logstash.logback:logstash-logback-encoder:${logstashEncoderVersion}") - implementation("io.micrometer:micrometer-tracing:${micrometerTracingVersion}") - implementation("io.micrometer:micrometer-tracing-bridge-otel:${micrometerTracingVersion}") - implementation("io.github.irgaly.kfswatch:kfswatch:$kfsWatchVersion") - implementation("com.mayakapps.kache:kache:$kacheVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$kotlinCoroutinesVersion") // to enable @Scheduled on Kotlin suspending functions + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.retry:spring-retry") kapt("org.springframework.boot:spring-boot-configuration-processor") @@ -112,12 +117,25 @@ kotlin { } } +tasks.test { + useJUnitPlatform { + excludeTags(Constants.INTEGRATION_TEST_TAG) + } +} + val jacocoTestCoverageVerification = tasks.named("jacocoTestCoverageVerification") { violationRules { /** * Ensure tests cover at least 75% of the LoC. */ rule { + classDirectories.setFrom(files(classDirectories.files.map { + fileTree(it) { + setExcludes(listOf( + "**/com/swisscom/health/des/cdr/clientvm/msal4j/*.class" + )) + } + })) limit { minimum = "0.75".toBigDecimal() } @@ -143,6 +161,11 @@ tasks.withType { includeEngines("junit-jupiter") } finalizedBy(jacocoTestReport) + + jvmArgs( + // tests_hosts is used to redirect msal4j, which insists on talking to the Mothership, to our docker compose setup + "-Djdk.net.hosts.file=${layout.projectDirectory.file("src/test/resources/test_hosts").asFile.absolutePath}" + ) } jacoco { @@ -217,3 +240,32 @@ publishing { } } } + +/*********************** + * Integration Testing * + ***********************/ +object Constants { + const val TASK_GROUP_VERIFICATION = "verification" + const val INTEGRATION_TEST_TAG = "integration-test" +} + +tasks.register("integrationTest") { + group = Constants.TASK_GROUP_VERIFICATION + useJUnitPlatform { + includeTags(Constants.INTEGRATION_TEST_TAG) + } + shouldRunAfter(tasks.test) + // Ensure latest images get pulled + dependsOn(tasks.composePull) +} + +dockerCompose { + dockerComposeWorkingDirectory.set(File("${rootProject.projectDir}/docker-compose")) + dockerComposeStopTimeout.set(Duration.ofSeconds(5)) // time before docker-compose sends SIGTERM to the running containers after the composeDown task has been started + ignorePullFailure.set(true) + isRequiredBy(tasks.getByName("integrationTest")) +} + +/*************************** + * END Integration Testing * + ***************************/ diff --git a/docker-compose/caddy/Caddyfile b/docker-compose/caddy/Caddyfile new file mode 100644 index 0000000..6664591 --- /dev/null +++ b/docker-compose/caddy/Caddyfile @@ -0,0 +1,12 @@ +{ + http_port 8080 + https_port 8443 +} +localhost { + reverse_proxy /isalive mock-oauth2-server:8080 +} +login.microsoftonline.com { + tls internal + reverse_proxy /common/discovery/* wiremock:8080 + reverse_proxy /test-tenant-id/* mock-oauth2-server:8080 +} diff --git a/docker-compose/caddy/Dockerfile b/docker-compose/caddy/Dockerfile new file mode 100644 index 0000000..0fd0112 --- /dev/null +++ b/docker-compose/caddy/Dockerfile @@ -0,0 +1,3 @@ +FROM caddy:2.8.4 +RUN apk update && apk upgrade && apk add curl +COPY ./Caddyfile /etc/caddy/Caddyfile diff --git a/docker-compose/docker-compose.yaml b/docker-compose/docker-compose.yaml index 512cc54..9fef277 100644 --- a/docker-compose/docker-compose.yaml +++ b/docker-compose/docker-compose.yaml @@ -1,12 +1,61 @@ services: wiremock: image: local.wiremock.cdrapi + container_name: wiremock-cdr-client build: ./wiremock mem_limit: 256m + healthcheck: + # use a mapped URL for health checking; the response is a 200 OK with a string body "OK" + test: curl --fail http://localhost:8080/health || exit 1 + interval: 5s + retries: 5 + start_period: 5s + timeout: 10s ports: - - "9090:8080" - - "8443:8443" + - "9090:8080" environment: - TZ=Europe/Zurich command: ["--verbose", "--https-port", "8443", "--global-response-templating"] + networks: + - cdr_client_net + + mock-oauth2-server: + image: ghcr.io/navikt/mock-oauth2-server:2.1.10 + container_name: mock-oauth2-server-cdr-client + environment: + - TZ=Europe/Zurich + - LOG_LEVEL=DEBUG + - JSON_CONFIG_PATH=/app/config.json + - SERVER_PORT=8080 + volumes: + - ./mockOAuth2Server/config.json:/app/config.json + - ./mockOAuth2Server/mockoauth2server.p12:/app/mockoauth2server.p12 + networks: + - cdr_client_net + + caddy: + depends_on: + wiremock: + condition: service_healthy + image: local.caddy.cdrappmgr + container_name: caddy-app-mgr + build: ./caddy + mem_limit: 256m + ports: + - "8443:8443" + healthcheck: + # checks the mock-oauth2-server's health endpoint to prove that both caddy and the mock-oauth2-server are up and running + test: curl --insecure --fail https://localhost:8443/isalive || exit 1 + interval: 5s + retries: 5 + start_period: 120s + timeout: 10s + networks: + - cdr_client_net +networks: + cdr_client_net: + name: cdr_client_net + ipam: + config: + - subnet: 10.113.0.0/16 diff --git a/docker-compose/mockOAuth2Server/README.md b/docker-compose/mockOAuth2Server/README.md new file mode 100644 index 0000000..f81319b --- /dev/null +++ b/docker-compose/mockOAuth2Server/README.md @@ -0,0 +1,44 @@ +# TLS Keystore For MockOAuth2Server + +Run your [own CA](https://pki-tutorial.readthedocs.io/en/latest/simple/) and issue yourself a +[key pair](./mockoauth2server.p12).
+If you want/need to repeat the process: + +1. check out the 2022 branch (or whatever branch appears to be the most up-to-date one) of this + [git repo](https://bitbucket.org/stefanholek/pki-example-1) and +2. change the policy "match_pol" of the signing ca config file to mark all fields as "supplied" so you get to set + the `DC`, `OU`, and `O` parts of the X509 certificate yourself: + ```shell + diff work/git/3rd_party/pki-example-1/etc/signing-ca.conf work/software/ca/etc/signing-ca.conf + 68,70c68,70 + < domainComponent = match # Must match 'simple.org' + < organizationName = match # Must match 'Simple Inc' + < organizationalUnitName = optional # Included if present + --- + > domainComponent = supplied # Must match 'simple.org' + > organizationName = supplied # Must match 'Simple Inc' + > organizationalUnitName = supplied # Included if present + ``` +3. follow the instructions to initialize the root and signing CAs (step 1 and 2) +4. create a sub-folder `certs` +5. then generated a new key pair for the Oauth2 Mock Server like so (note that the `SAN` must match the service name in + the [docker-compose.yml](../docker-compose.yaml)): + ```shell + SAN=DNS:mock-oauth2-server,DNS:localhost,DNS:host.docker.internal \ + openssl req -new \ + -config etc/server.conf \ + -out certs/mockoauth2server.csr \ + -keyout certs/mockoauth2server.key + + openssl ca \ + -config etc/signing-ca.conf \ + -in certs/mockoauth2server.csr \ + -out certs/mockoauth2server.crt \ + -extensions server_ext + + openssl pkcs12 -export \ + -name "OAuth2 Local Development" \ + -inkey certs/mockoauth2server.key \ + -in certs/mockoauth2server.crt \ + -out certs/mockoauth2server.p12 + ``` diff --git a/docker-compose/mockOAuth2Server/config.json b/docker-compose/mockOAuth2Server/config.json new file mode 100644 index 0000000..d39828e --- /dev/null +++ b/docker-compose/mockOAuth2Server/config.json @@ -0,0 +1,30 @@ +{ + "interactiveLogin": false, + "httpServer": { + "type": "NettyWrapper" + }, + "tokenProvider" : { + "keyProvider" : { + "algorithm" : "ES256" + } + }, + "tokenCallbacks": [ + { + "issuerId": "test-tenant-id/oauth2/v2.0", + "tokenExpiry":360, + "requestMappings": [ + { + "requestParam": "client_id", + "match": "*", + "claims": { + "sub": "${clientId}", + "roles": [ + "CdrApi.ReadWrite.OwnedBy", + "AppRoleAssignment.ReadWrite.All" + ] + } + } + ] + } + ] +} diff --git a/docker-compose/mockOAuth2Server/mockoauth2server.p12 b/docker-compose/mockOAuth2Server/mockoauth2server.p12 new file mode 100644 index 0000000..b3f1922 Binary files /dev/null and b/docker-compose/mockOAuth2Server/mockoauth2server.p12 differ diff --git a/docker-compose/mockOAuth2Server/root-ca.crt b/docker-compose/mockOAuth2Server/root-ca.crt new file mode 100644 index 0000000..6900c1f --- /dev/null +++ b/docker-compose/mockOAuth2Server/root-ca.crt @@ -0,0 +1,82 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 3a:3a:a5:a6:e6:e8:31:52:d0:b6:5c:8e:ef:11:d2:cd:4d:19:75:04 + Signature Algorithm: sha256WithRSAEncryption + Issuer: DC=org, DC=simple, O=Simple Inc, CN=Simple Root CA + Validity + Not Before: Oct 30 12:33:55 2024 GMT + Not After : Oct 30 12:33:55 2034 GMT + Subject: DC=org, DC=simple, O=Simple Inc, CN=Simple Root CA + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b8:6c:c2:f0:ab:0b:03:40:9f:53:e3:5b:e0:8c: + 58:fa:0f:00:7d:43:fe:e9:0c:fb:6f:2e:30:82:18: + 4d:ee:03:61:24:87:7b:eb:b7:69:c0:dc:d3:19:b8: + aa:c1:ee:47:0c:44:1f:16:16:d6:a7:8c:70:12:16: + c7:80:59:eb:0b:dd:be:0d:0b:1f:05:2a:70:c2:87: + 16:36:11:b3:ba:38:fb:b3:d2:51:88:19:dc:bf:bc: + 4f:90:92:27:2a:f5:c9:a9:ff:df:e0:ae:97:e0:fd: + 20:2d:b7:63:6b:18:0d:8d:6c:07:83:9d:7f:29:09: + 3d:19:74:01:33:e5:74:c7:07:c4:6c:5d:c5:e5:d4: + 66:64:46:88:e5:1f:9f:d3:01:8e:4b:64:d4:65:15: + ef:eb:93:e9:d9:be:f9:5a:d2:0b:4a:64:e1:ae:20: + 2f:d1:72:c2:40:cc:bf:5f:20:96:6d:be:63:38:44: + 8c:f5:bf:12:f9:09:40:cf:77:98:f0:d2:16:9b:a7: + 10:d0:9a:3e:b6:a6:08:56:26:80:25:b9:0a:02:b1: + ca:b3:2f:89:92:29:d2:57:ca:f6:fc:13:39:57:73: + 85:79:11:49:76:29:b2:d2:55:3d:97:7c:47:85:18: + 44:95:90:53:f2:37:e2:33:c6:23:d4:d8:5f:58:01: + 44:bb + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Key Usage: critical + Certificate Sign, CRL Sign + X509v3 Basic Constraints: critical + CA:TRUE + X509v3 Subject Key Identifier: + 94:D3:B4:A5:BB:AC:BC:8E:7D:41:7F:40:2A:9A:54:90:B4:F3:1E:FD + X509v3 Authority Key Identifier: + 94:D3:B4:A5:BB:AC:BC:8E:7D:41:7F:40:2A:9A:54:90:B4:F3:1E:FD + Signature Algorithm: sha256WithRSAEncryption + Signature Value: + 2f:98:ba:d8:2f:1f:70:ef:0a:83:d5:e8:f4:cb:cf:11:29:f3: + f8:63:1d:8c:9b:2c:c7:2b:13:3d:8d:3d:21:c4:d5:40:85:b0: + b9:6a:28:a7:4e:ba:09:50:46:0f:38:fe:9b:6a:b1:52:97:5e: + be:b8:45:0e:26:17:fb:ba:3d:06:9f:79:cc:58:15:19:e3:9b: + 40:52:90:1b:42:42:8a:df:6b:54:b6:f2:fd:46:28:1e:f1:5c: + 77:2c:e9:ad:4b:4d:b2:f4:2a:8d:df:75:39:22:36:a1:77:0c: + 44:b7:32:ec:c8:d5:ce:3a:57:31:3c:12:12:7d:b4:c9:78:c6: + cd:42:4e:78:9c:63:59:ab:2b:d2:7c:64:52:84:55:31:90:ec: + 83:39:36:d6:56:18:9c:ec:72:42:d7:25:04:4e:80:c3:11:1b: + 6b:41:3f:96:d6:4e:cf:c5:07:d6:da:46:28:87:b2:f9:64:f9: + b5:62:26:a5:e5:40:50:37:5a:55:40:c3:59:95:3c:24:51:b0: + 5c:a0:bf:d6:41:3f:bd:62:c5:3d:2d:da:29:98:61:fe:a2:f3: + 88:d2:09:43:77:5d:48:b3:38:3f:e3:11:ff:78:14:54:b8:79: + 56:fc:b7:fb:12:58:a8:f4:3e:76:a5:e5:36:86:db:0b:e6:06: + b1:c5:8d:fe +-----BEGIN CERTIFICATE----- +MIIDpzCCAo+gAwIBAgIUOjqlpuboMVLQtlyO7xHSzU0ZdQQwDQYJKoZIhvcNAQEL +BQAwWzETMBEGCgmSJomT8ixkARkWA29yZzEWMBQGCgmSJomT8ixkARkWBnNpbXBs +ZTETMBEGA1UECgwKU2ltcGxlIEluYzEXMBUGA1UEAwwOU2ltcGxlIFJvb3QgQ0Ew +HhcNMjQxMDMwMTIzMzU1WhcNMzQxMDMwMTIzMzU1WjBbMRMwEQYKCZImiZPyLGQB +GRYDb3JnMRYwFAYKCZImiZPyLGQBGRYGc2ltcGxlMRMwEQYDVQQKDApTaW1wbGUg +SW5jMRcwFQYDVQQDDA5TaW1wbGUgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALhswvCrCwNAn1PjW+CMWPoPAH1D/ukM+28uMIIYTe4DYSSH +e+u3acDc0xm4qsHuRwxEHxYW1qeMcBIWx4BZ6wvdvg0LHwUqcMKHFjYRs7o4+7PS +UYgZ3L+8T5CSJyr1yan/3+Cul+D9IC23Y2sYDY1sB4OdfykJPRl0ATPldMcHxGxd +xeXUZmRGiOUfn9MBjktk1GUV7+uT6dm++VrSC0pk4a4gL9FywkDMv18glm2+YzhE +jPW/EvkJQM93mPDSFpunENCaPramCFYmgCW5CgKxyrMviZIp0lfK9vwTOVdzhXkR +SXYpstJVPZd8R4UYRJWQU/I34jPGI9TYX1gBRLsCAwEAAaNjMGEwDgYDVR0PAQH/ +BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFJTTtKW7rLyOfUF/QCqa +VJC08x79MB8GA1UdIwQYMBaAFJTTtKW7rLyOfUF/QCqaVJC08x79MA0GCSqGSIb3 +DQEBCwUAA4IBAQAvmLrYLx9w7wqD1ej0y88RKfP4Yx2MmyzHKxM9jT0hxNVAhbC5 +aiinTroJUEYPOP6barFSl16+uEUOJhf7uj0Gn3nMWBUZ45tAUpAbQkKK32tUtvL9 +Rige8Vx3LOmtS02y9CqN33U5IjahdwxEtzLsyNXOOlcxPBISfbTJeMbNQk54nGNZ +qyvSfGRShFUxkOyDOTbWVhic7HJC1yUEToDDERtrQT+W1k7PxQfW2kYoh7L5ZPm1 +Yial5UBQN1pVQMNZlTwkUbBcoL/WQT+9YsU9LdopmGH+ovOI0glDd11Iszg/4xH/ +eBRUuHlW/Lf7Elio9D52peU2htsL5gaxxY3+ +-----END CERTIFICATE----- diff --git a/docker-compose/mockOAuth2Server/signing-ca.crt b/docker-compose/mockOAuth2Server/signing-ca.crt new file mode 100644 index 0000000..afda1e5 --- /dev/null +++ b/docker-compose/mockOAuth2Server/signing-ca.crt @@ -0,0 +1,82 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 28:cc:34:1c:90:a2:6b:d2:d3:61:7d:2f:ef:71:b6:20:34:f3:22:fc + Signature Algorithm: sha256WithRSAEncryption + Issuer: DC=org, DC=simple, O=Simple Inc, CN=Simple Root CA + Validity + Not Before: Oct 30 12:34:23 2024 GMT + Not After : Oct 30 12:34:23 2034 GMT + Subject: DC=org, DC=simple, O=Simple Inc, CN=Simple Signing CA + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:a6:b6:e9:5c:51:c1:96:82:a2:aa:35:c1:97:17: + 3d:04:31:bb:e3:72:05:1d:ae:78:11:9b:f3:bb:47: + 29:c1:d6:77:5c:42:7e:bf:b3:33:ca:75:aa:be:e0: + f2:28:6e:26:08:6c:7c:aa:bc:ea:25:99:d1:10:99: + eb:2d:a3:0b:b4:be:62:46:a0:d6:7e:2d:51:58:6a: + 3f:d9:de:8b:32:63:93:2d:73:d0:f5:40:6d:ab:d8: + 07:b6:48:e3:e3:8d:59:d5:8c:74:57:07:3f:96:e9: + 90:f6:52:6c:4c:91:ed:3d:c9:59:26:ed:b7:9c:99: + c3:08:eb:0b:1b:d9:75:31:fb:23:61:b4:80:b9:41: + 8f:11:57:1f:f5:41:a6:f6:1c:72:94:23:10:3b:27: + a3:d6:4e:27:7b:f4:54:ee:db:e3:47:50:92:19:25: + b9:b8:7f:71:4b:fa:c3:2c:56:95:9c:8d:62:c2:68: + fc:bc:3a:b5:56:11:09:a5:47:ab:e3:d2:8c:f2:b8: + 9c:96:86:18:be:72:91:05:da:51:1f:73:a3:c4:a4: + 1d:5e:b8:ad:15:be:52:63:5c:c8:8b:ba:47:6e:7a: + da:5d:2b:61:3d:c2:01:7c:59:30:f6:b9:c4:50:0a: + f2:2d:35:72:2c:b3:31:d6:fb:98:f2:79:88:a5:fa: + 6f:6d + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Key Usage: critical + Certificate Sign, CRL Sign + X509v3 Basic Constraints: critical + CA:TRUE, pathlen:0 + X509v3 Subject Key Identifier: + D1:3C:28:9A:83:BB:38:9D:1A:B8:2C:61:30:F2:2C:A7:62:94:58:3B + X509v3 Authority Key Identifier: + 94:D3:B4:A5:BB:AC:BC:8E:7D:41:7F:40:2A:9A:54:90:B4:F3:1E:FD + Signature Algorithm: sha256WithRSAEncryption + Signature Value: + 46:6f:6d:56:26:06:ea:10:8d:00:27:3b:5f:a5:6b:8c:73:18: + cb:ae:94:5f:96:4c:67:0b:09:10:ed:62:c3:68:4e:eb:e3:c0: + 4f:0a:6a:81:c3:b2:e4:3f:f3:31:01:ce:fd:0d:e4:97:c6:8f: + d9:d2:9d:6d:3d:3e:d0:7f:0c:24:57:5e:c9:aa:46:b8:31:5f: + 34:a5:c8:95:7f:ff:71:44:34:c3:ab:95:0b:d6:cf:51:85:68: + d0:79:aa:46:89:26:92:e7:68:15:f5:cf:5b:c6:ec:18:9e:0b: + 05:d2:10:41:a1:a4:fd:a6:52:d8:6f:e6:4e:0b:4d:5c:b7:3e: + 3a:6a:37:92:7a:ee:38:58:51:9d:2d:a7:f3:5b:09:5b:12:d7: + a9:c6:00:66:79:b4:d6:f4:0b:e8:62:e1:23:62:8e:c8:a5:ec: + 2e:8a:95:2b:09:63:02:95:64:69:94:00:3d:3e:51:69:84:9f: + 1e:ba:2c:03:7c:62:97:9f:ce:06:9b:33:0c:e5:47:72:ab:4d: + 19:d3:72:4b:3f:24:8f:57:91:ce:bf:78:c4:16:93:81:7c:71: + c7:e6:0b:2e:69:7d:d9:ad:86:ec:4b:66:e6:06:82:59:b9:6f: + e6:88:82:70:fe:16:78:36:1a:8b:0c:70:40:d3:d3:3b:82:1f: + af:cd:46:86 +-----BEGIN CERTIFICATE----- +MIIDrTCCApWgAwIBAgIUKMw0HJCia9LTYX0v73G2IDTzIvwwDQYJKoZIhvcNAQEL +BQAwWzETMBEGCgmSJomT8ixkARkWA29yZzEWMBQGCgmSJomT8ixkARkWBnNpbXBs +ZTETMBEGA1UECgwKU2ltcGxlIEluYzEXMBUGA1UEAwwOU2ltcGxlIFJvb3QgQ0Ew +HhcNMjQxMDMwMTIzNDIzWhcNMzQxMDMwMTIzNDIzWjBeMRMwEQYKCZImiZPyLGQB +GRYDb3JnMRYwFAYKCZImiZPyLGQBGRYGc2ltcGxlMRMwEQYDVQQKDApTaW1wbGUg +SW5jMRowGAYDVQQDDBFTaW1wbGUgU2lnbmluZyBDQTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAKa26VxRwZaCoqo1wZcXPQQxu+NyBR2ueBGb87tHKcHW +d1xCfr+zM8p1qr7g8ihuJghsfKq86iWZ0RCZ6y2jC7S+Ykag1n4tUVhqP9neizJj +ky1z0PVAbavYB7ZI4+ONWdWMdFcHP5bpkPZSbEyR7T3JWSbtt5yZwwjrCxvZdTH7 +I2G0gLlBjxFXH/VBpvYccpQjEDsno9ZOJ3v0VO7b40dQkhklubh/cUv6wyxWlZyN +YsJo/Lw6tVYRCaVHq+PSjPK4nJaGGL5ykQXaUR9zo8SkHV64rRW+UmNcyIu6R256 +2l0rYT3CAXxZMPa5xFAK8i01ciyzMdb7mPJ5iKX6b20CAwEAAaNmMGQwDgYDVR0P +AQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFNE8KJqDuzid +GrgsYTDyLKdilFg7MB8GA1UdIwQYMBaAFJTTtKW7rLyOfUF/QCqaVJC08x79MA0G +CSqGSIb3DQEBCwUAA4IBAQBGb21WJgbqEI0AJztfpWuMcxjLrpRflkxnCwkQ7WLD +aE7r48BPCmqBw7LkP/MxAc79DeSXxo/Z0p1tPT7QfwwkV17Jqka4MV80pciVf/9x +RDTDq5UL1s9RhWjQeapGiSaS52gV9c9bxuwYngsF0hBBoaT9plLYb+ZOC01ctz46 +ajeSeu44WFGdLafzWwlbEtepxgBmebTW9AvoYuEjYo7IpewuipUrCWMClWRplAA9 +PlFphJ8euiwDfGKXn84GmzMM5Udyq00Z03JLPySPV5HOv3jEFpOBfHHH5gsuaX3Z +rYbsS2bmBoJZuW/miIJw/hZ4NhqLDHBA09M7gh+vzUaG +-----END CERTIFICATE----- diff --git a/docker-compose/wiremock/Dockerfile b/docker-compose/wiremock/Dockerfile index 6186de3..214e72d 100644 --- a/docker-compose/wiremock/Dockerfile +++ b/docker-compose/wiremock/Dockerfile @@ -1,3 +1,5 @@ -FROM wiremock/wiremock:3.6.0-alpine +FROM wiremock/wiremock:3.9.2-1-alpine + +RUN apk update && apk upgrade && apk add curl COPY ./mappings/* /home/wiremock/mappings/ diff --git a/docker-compose/wiremock/mappings/discovery.json b/docker-compose/wiremock/mappings/discovery.json new file mode 100644 index 0000000..e0cea76 --- /dev/null +++ b/docker-compose/wiremock/mappings/discovery.json @@ -0,0 +1,14 @@ +{ + "request": { + "urlPattern": "/common/discovery/instance.*", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json", + "client-request-id": "{{request.headers.client-request-id}}" + }, + "body": "{\"tenant_discovery_endpoint\":\"https://login.microsoftonline.com:8443/common/.well-known/openid-configuration\",\"api-version\":\"1.1\",\"metadata\":[{\"preferred_network\":\"login.microsoftonline.com\",\"preferred_cache\":\"login.microsoftonline.com\",\"aliases\":[\"login.microsoftonline.com\"]}]}" + } +} diff --git a/docker-compose/wiremock/mappings/health.json b/docker-compose/wiremock/mappings/health.json new file mode 100644 index 0000000..b893e64 --- /dev/null +++ b/docker-compose/wiremock/mappings/health.json @@ -0,0 +1,13 @@ +{ + "request": { + "urlPattern": "/health", + "method": "GET" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "OK" + } +} diff --git a/gradle.properties b/gradle.properties index bd0b362..f90a064 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,8 @@ org.gradle.java.installations.fromEnv=JDK17,JDK21 # Kotlin ############ jvmVersion=17 -kotlinVersion=2.0.21 kotlinCoroutinesVersion=1.9.0 +kotlinVersion=2.0.21 ############ # Plugins ############ @@ -22,14 +22,14 @@ springDependencyManagementVersion=1.1.6 ############ # Dependencies ############ +awaitilityVersion=4.2.2 jacocoVersion=0.8.12 +kacheVersion=2.1.0 +kfsWatchVersion=1.3.0 kotlinLoggingVersion=7.0.0 logstashEncoderVersion=8.0 micrometerTracingVersion=1.3.5 mockkVersion=1.13.12 +msal4jVersion=1.17.2 springCloudVersion=2023.0.3 -kfsWatchVersion=1.3.0 -kacheVersion=2.1.0 springMockkVersion=4.0.2 -awaitilityVersion=4.2.2 - diff --git a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfig.kt b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfig.kt index cd71542..45ac7ea 100644 --- a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfig.kt +++ b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfig.kt @@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.http.MediaType import org.springframework.util.unit.DataSize +import java.net.URL import java.nio.file.Path import java.time.Duration import kotlin.io.path.createDirectories @@ -21,15 +22,17 @@ private val logger = KotlinLogging.logger {} */ @ConfigurationProperties("client") data class CdrClientConfig( + val customer: List, + val endpoint: Endpoint, + val filesInProgressCacheSize: DataSize, val functionKey: String, - val scheduleDelay: Duration, + val idpCredentials: IdpCredentials, + val idpEndpoint: URL, val localFolder: Path, - val endpoint: Endpoint, - val customer: List, val pullThreadPoolSize: Int, val pushThreadPoolSize: Int, val retryDelay: List, - val filesInProgressCacheSize: DataSize + val scheduleDelay: Duration ) { /** @@ -117,8 +120,19 @@ data class CdrClientConfig( } override fun toString(): String { - return "CdrClientConfig(functionKey='xxx', scheduleDelay='$scheduleDelay', localFolder='$localFolder', " + - "customer=$customer, endpoint=$endpoint)" + return "CdrClientConfig(idpCredentials='${idpCredentials}', idpEndpoint='${idpEndpoint}', localFolder='$localFolder', " + + "customer=$customer, endpoint=$endpoint, scheduleDelay='$scheduleDelay', retryDelay='${retryDelay.joinToString { it.toString() }}')" + } + + data class IdpCredentials( + val tenantId: String, + val clientId: String, + val clientSecret: String, + val scopes: List, + ) { + override fun toString(): String { + return "IdpCredentials(tenantId='$tenantId', clientId='$clientId', clientSecret='********', scopes=$scopes)" + } } } diff --git a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientContext.kt b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientContext.kt index 8815b27..8053654 100644 --- a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientContext.kt +++ b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientContext.kt @@ -3,6 +3,10 @@ package com.swisscom.health.des.cdr.clientvm.config import com.mayakapps.kache.InMemoryKache import com.mayakapps.kache.KacheStrategy import com.mayakapps.kache.ObjectKache +import com.microsoft.aad.msal4j.ClientCredentialFactory +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.ConfidentialClientApplication +import com.microsoft.aad.msal4j.IConfidentialClientApplication import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -12,8 +16,13 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.retry.support.RetryTemplate +import java.io.IOException import java.nio.file.Path import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration private val logger = KotlinLogging.logger {} @@ -77,4 +86,25 @@ class CdrClientContext { } } + @Bean + fun confidentialClientApp(config: CdrClientConfig): IConfidentialClientApplication = + ConfidentialClientApplication.builder( + config.idpCredentials.clientId, + ClientCredentialFactory.createFromSecret(config.idpCredentials.clientSecret) + ).authority(config.idpEndpoint.toString()) + .build() + + @Bean + fun clientCredentialParams(config: CdrClientConfig): ClientCredentialParameters = + ClientCredentialParameters.builder(config.idpCredentials.scopes.toSet()).build() + + @Bean(name = ["retryIoErrorsThrice"]) + @Suppress("MagicNumber") + fun retryIOExceptionsThreeTimesTemplate(): RetryTemplate = RetryTemplate.builder() + .maxAttempts(3) + .exponentialBackoff(100.milliseconds.toJavaDuration(), 5.0, 1.seconds.toJavaDuration(), true) + .retryOn(IOException::class.java) + .traversingCauses() + .build() + } diff --git a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/FileHandlingBase.kt b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/FileHandlingBase.kt index e8efbfb..9393dcf 100644 --- a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/FileHandlingBase.kt +++ b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/FileHandlingBase.kt @@ -1,9 +1,15 @@ package com.swisscom.health.des.cdr.clientvm.handler +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IAuthenticationResult +import com.microsoft.aad.msal4j.IConfidentialClientApplication import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging import io.micrometer.tracing.Tracer import okhttp3.Headers +import org.springframework.http.HttpHeaders +import org.springframework.retry.support.RetryTemplate import org.springframework.util.LinkedMultiValueMap import org.springframework.util.MultiValueMap import org.springframework.web.util.UriComponentsBuilder @@ -11,7 +17,7 @@ import java.net.URL import java.nio.file.Files import java.nio.file.Path - +private val logger: KLogger = KotlinLogging.logger {} internal const val FUNCTION_KEY_HEADER = "x-functions-key" internal const val CONNECTOR_ID_HEADER = "cdr-connector-id" internal const val CDR_PROCESSING_MODE_HEADER = "cdr-processing-mode" @@ -39,7 +45,13 @@ fun pathIsDirectoryAndWritable(path: Path, what: String, logger: KLogger): Boole /** * Basic functionality needed in all FileHandling classes */ -abstract class FileHandlingBase(protected val cdrClientConfig: CdrClientConfig, private val tracer: Tracer) { +abstract class FileHandlingBase( + protected val cdrClientConfig: CdrClientConfig, + private val clientCredentialParams: ClientCredentialParameters, + private val retryIoErrorsThrice: RetryTemplate, + private val securedApp: IConfidentialClientApplication, + private val tracer: Tracer +) { /** * Builds a target URL from an endpoint and a path. @@ -61,15 +73,24 @@ abstract class FileHandlingBase(protected val cdrClientConfig: CdrClientConfig, } /** - * Build headers with connector-id, function key, processing mode and trace id. + * Build headers with connector-id, access token, processing mode and trace id. */ protected fun buildBaseHeaders(connectorId: String, mode: CdrClientConfig.Mode): Headers { val traceId = tracer.currentSpan()?.context()?.traceId() ?: "" + // TODO: Remove this check once the token is required + val accessToken = if (cdrClientConfig.idpCredentials.tenantId != NO_TOKEN_TENANT_ID) { + getAccessToken() + } else { + null + } return Headers.Builder().run { this[CONNECTOR_ID_HEADER] = connectorId this[FUNCTION_KEY_HEADER] = cdrClientConfig.functionKey this[CDR_PROCESSING_MODE_HEADER] = mode.value this[AZURE_TRACE_ID_HEADER] = traceId + if (accessToken != null) { + this[HttpHeaders.AUTHORIZATION] = "Bearer $accessToken" + } this.build() } } @@ -84,4 +105,22 @@ abstract class FileHandlingBase(protected val cdrClientConfig: CdrClientConfig, } } + private fun getAccessToken(): String = runCatching { + retryIoErrorsThrice.execute { _ -> + val authResult: IAuthenticationResult = securedApp.acquireToken(clientCredentialParams).get() + logger.debug { "Token taken from ${authResult.metadata().tokenSource()}" } + authResult.accessToken() + } + }.fold( + onSuccess = { token: String -> token }, + onFailure = { e -> + logger.error(e) { "Failed to get access token: ${e.message}" } + "" + } + ) + + companion object { + private const val NO_TOKEN_TENANT_ID = "no-token" + } + } diff --git a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandling.kt b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandling.kt index 5481402..637f744 100644 --- a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandling.kt +++ b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandling.kt @@ -1,5 +1,7 @@ package com.swisscom.health.des.cdr.clientvm.handler +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IConfidentialClientApplication import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.github.oshai.kotlinlogging.KotlinLogging import io.micrometer.tracing.Tracer @@ -7,7 +9,9 @@ import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.HttpStatus +import org.springframework.retry.support.RetryTemplate import org.springframework.stereotype.Component import org.springframework.util.LinkedMultiValueMap import java.io.IOException @@ -30,8 +34,12 @@ internal const val PULL_RESULT_ID_HEADER = "cdr-document-uuid" class PullFileHandling( cdrClientConfig: CdrClientConfig, private val httpClient: OkHttpClient, + clientCredentialParams: ClientCredentialParameters, + @Qualifier("retryIoErrorsThrice") + private val retryIoErrorsThrice: RetryTemplate, + securedApp: IConfidentialClientApplication, tracer: Tracer, -) : FileHandlingBase(cdrClientConfig, tracer) { +) : FileHandlingBase(cdrClientConfig, clientCredentialParams, retryIoErrorsThrice, securedApp, tracer) { /** * Downloads files for a specific customer. * diff --git a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PushFileHandling.kt b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PushFileHandling.kt index 7a7715e..46c83f4 100644 --- a/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PushFileHandling.kt +++ b/src/main/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PushFileHandling.kt @@ -1,6 +1,8 @@ package com.swisscom.health.des.cdr.clientvm.handler import com.mayakapps.kache.ObjectKache +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IConfidentialClientApplication import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.github.oshai.kotlinlogging.KotlinLogging import io.micrometer.tracing.Tracer @@ -12,7 +14,9 @@ import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.HttpStatus +import org.springframework.retry.support.RetryTemplate import org.springframework.stereotype.Component import java.net.URL import java.nio.file.Files @@ -34,13 +38,17 @@ private val logger = KotlinLogging.logger {} * Deletes the local file after successful upload. */ @Component -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") class PushFileHandling( cdrClientConfig: CdrClientConfig, tracer: Tracer, private val httpClient: OkHttpClient, private val processingInProgressCache: ObjectKache, -) : FileHandlingBase(cdrClientConfig, tracer) { + clientCredentialParams: ClientCredentialParameters, + @Qualifier("retryIoErrorsThrice") + private val retryIoErrorsThrice: RetryTemplate, + securedApp: IConfidentialClientApplication, +) : FileHandlingBase(cdrClientConfig, clientCredentialParams, retryIoErrorsThrice, securedApp, tracer) { /** * Retries the upload of a file until it is successful or a 4xx error occurred. diff --git a/src/main/resources/config/application-client.yaml b/src/main/resources/config/application-client.yaml index aa8093a..e73062e 100644 --- a/src/main/resources/config/application-client.yaml +++ b/src/main/resources/config/application-client.yaml @@ -1,6 +1,13 @@ client: function-key: ${cdrClient.functionKey:} local-folder: ${cdrClient.localFolder:${java.io.tmpdir}/cdr_download} + idp-credentials: + tenant-id: ${CDR_B2C_TENANT_ID:no-token} + client-id: ${CDR_CLIENT_ID:id} + client-secret: ${CDR_CLIENT_SECRET:dummy} + scopes: + - https://graph.microsoft.com/.default + idp-endpoint: https://login.microsoftonline.com/${client.idp-credentials.tenant-id}/ schedule-delay: PT10M files-in-progress-cache-size: 10MB connection-timeout-ms: 5000 diff --git a/src/main/resources/config/application-dev.yaml b/src/main/resources/config/application-dev.yaml index 3a81e8d..293bffc 100644 --- a/src/main/resources/config/application-dev.yaml +++ b/src/main/resources/config/application-dev.yaml @@ -1,5 +1,10 @@ client: schedule-delay: PT30S + idp-credentials: + tenant-id: ${CDR_B2C_TENANT_ID:no-token} + client-id: ${CDR_CLIENT_ID:id} + client-secret: ${CDR_CLIENT_SECRET:dummy} + idp-endpoint: https://login.microsoftonline.com/${client.idp-credentials.tenant-id}/ endpoint: scheme: http port: 9090 diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/CdrClientApplicationTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/CdrClientApplicationTest.kt index 818e528..db97396 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/CdrClientApplicationTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/CdrClientApplicationTest.kt @@ -1,17 +1,26 @@ package com.swisscom.health.des.cdr.clientvm import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import java.nio.file.Path +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.NONE ) @ActiveProfiles("test") +@Tag("integration-test") internal class CdrClientApplicationTest { @Autowired @@ -27,6 +36,28 @@ internal class CdrClientApplicationTest { @JvmStatic @Suppress("unused") private lateinit var inflightDirInApplicationTestYaml: Path + + @JvmStatic + @BeforeAll + fun showMsalTheWayToFakeMicrosoftHost() { + val contextTls12: SSLContext = SSLContext.getInstance("TLSv1.2") + val contextTls13: SSLContext = SSLContext.getInstance("TLSv1.3") + val trustManager: Array = + arrayOf(object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOfNulls(0) + override fun checkClientTrusted(certificate: Array?, str: String?) { + // noop + } + override fun checkServerTrusted(certificate: Array?, str: String?) { + // noop + } + } + ) + contextTls12.init(null, trustManager, SecureRandom()) + contextTls13.init(null, trustManager, SecureRandom()) + HttpsURLConnection.setDefaultSSLSocketFactory(contextTls13.socketFactory) + } } } + diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest.kt index 3e6df07..39ee4fa 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest.kt @@ -1,5 +1,9 @@ package com.swisscom.health.des.cdr.clientvm +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IAuthenticationResult +import com.microsoft.aad.msal4j.IConfidentialClientApplication +import com.microsoft.aad.msal4j.TokenSource import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import com.swisscom.health.des.cdr.clientvm.handler.CONNECTOR_ID_HEADER import com.swisscom.health.des.cdr.clientvm.handler.PULL_RESULT_ID_HEADER @@ -11,6 +15,7 @@ import io.micrometer.tracing.Tracer import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension +import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -31,9 +36,12 @@ import org.junit.jupiter.api.io.TempDir import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.retry.RetryCallback +import org.springframework.retry.support.RetryTemplate import java.nio.charset.StandardCharsets import java.nio.file.Path import java.util.UUID +import java.util.concurrent.CompletableFuture import kotlin.io.path.createDirectories import kotlin.io.path.extension import kotlin.io.path.listDirectoryEntries @@ -59,6 +67,15 @@ internal class PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest @MockK private lateinit var traceContext: TraceContext + @MockK + private lateinit var clientCredentialParams: ClientCredentialParameters + + @MockK + private lateinit var retryIoErrorsThrice: RetryTemplate + + @MockK + private lateinit var securedApp: IConfidentialClientApplication + @TempDir private lateinit var tmpDir: Path @@ -115,10 +132,20 @@ internal class PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest every { config.endpoint } returns endpoint every { config.localFolder } returns localFolder every { config.functionKey } returns "1" + every { config.idpCredentials.tenantId } returns "something" + + every { retryIoErrorsThrice.execute(any>()) } answers { "Mocked Result" } + + val resultMock: CompletableFuture = mockk() + val authMock: IAuthenticationResult = mockk() + every { resultMock.get() } returns authMock + every { authMock.metadata().tokenSource() } returns TokenSource.CACHE + every { authMock.accessToken() } returns "123" + every { securedApp.acquireToken(any()) } returns resultMock mockTracer() - pullFileHandling = PullFileHandling(config, OkHttpClient.Builder().build(), tracer) + pullFileHandling = PullFileHandling(config, OkHttpClient.Builder().build(), clientCredentialParams, retryIoErrorsThrice, securedApp, tracer) documentDownloadScheduler = DocumentDownloadScheduler( config, pullFileHandling, @@ -242,3 +269,4 @@ internal class PullDocumentDownloadSchedulerAndFileHandlerMultipleConnectorTest } } + diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfigTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfigTest.kt index 3a8435b..ffb58bf 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfigTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/config/CdrClientConfigTest.kt @@ -1,5 +1,6 @@ package com.swisscom.health.des.cdr.clientvm.config +import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig.IdpCredentials import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow @@ -7,6 +8,7 @@ import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir import org.springframework.http.MediaType import org.springframework.util.unit.DataSize +import java.net.URL import java.nio.file.Path import java.time.Duration @@ -134,7 +136,14 @@ class CdrClientConfigTest { pullThreadPoolSize = 1, pushThreadPoolSize = 1, retryDelay = listOf(Duration.ofSeconds(1)), - filesInProgressCacheSize = DataSize.ofMegabytes(1) + filesInProgressCacheSize = DataSize.ofMegabytes(1), + idpCredentials = IdpCredentials( + tenantId = "tenantId", + clientId = "clientId", + clientSecret = "secret", + scopes = listOf("CDR") + ), + idpEndpoint = URL("http://localhost") ) } diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/EventPushFileHandlingTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/EventPushFileHandlingTest.kt index ef92321..cf584f2 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/EventPushFileHandlingTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/EventPushFileHandlingTest.kt @@ -1,10 +1,15 @@ package com.swisscom.health.des.cdr.clientvm.handler import com.mayakapps.kache.ObjectKache +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IAuthenticationResult +import com.microsoft.aad.msal4j.IConfidentialClientApplication +import com.microsoft.aad.msal4j.TokenSource import com.ninjasquad.springmockk.SpykBean import com.swisscom.health.des.cdr.clientvm.AlwaysSameTempDirFactory import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -28,6 +33,7 @@ import org.springframework.test.annotation.DirtiesContext.ClassMode import org.springframework.test.context.ActiveProfiles import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.ExperimentalPathApi @@ -53,6 +59,9 @@ internal class EventPushFileHandlingTest { @SpykBean private lateinit var config: CdrClientConfig + @SpykBean + private lateinit var securedApp: IConfidentialClientApplication + @Autowired private lateinit var fileCache: ObjectKache @@ -94,6 +103,13 @@ internal class EventPushFileHandlingTest { ) ) + val resultMock: CompletableFuture = mockk() + val authMock: IAuthenticationResult = mockk() + every { resultMock.get() } returns authMock + every { authMock.metadata().tokenSource() } returns TokenSource.CACHE + every { authMock.accessToken() } returns "123" + every { securedApp.acquireToken(any()) } returns resultMock + // The test is in a race condition with the code under test. The code under test starts a file watcher task with the help of the // `@Scheduled` annotation. I have found no deterministic way to either delay the scheduled task until we set the behavior on the // spy of the CdrClientConfig bean, to not run it at all, or to cancel it if it is already running. diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PollingPushFileHandlingTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PollingPushFileHandlingTest.kt index cec79b1..26a8cbd 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PollingPushFileHandlingTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PollingPushFileHandlingTest.kt @@ -1,10 +1,15 @@ package com.swisscom.health.des.cdr.clientvm.handler import com.mayakapps.kache.ObjectKache +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IAuthenticationResult +import com.microsoft.aad.msal4j.IConfidentialClientApplication +import com.microsoft.aad.msal4j.TokenSource import com.ninjasquad.springmockk.SpykBean import com.swisscom.health.des.cdr.clientvm.AlwaysSameTempDirFactory import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -24,6 +29,7 @@ import org.springframework.test.annotation.DirtiesContext.ClassMode import org.springframework.test.context.ActiveProfiles import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.createDirectories @@ -48,6 +54,9 @@ internal class PollingPushFileHandlingTest { @SpykBean private lateinit var config: CdrClientConfig + @SpykBean + private lateinit var securedApp: IConfidentialClientApplication + @Autowired private lateinit var fileCache: ObjectKache @@ -86,6 +95,13 @@ internal class PollingPushFileHandlingTest { mode = CdrClientConfig.Mode.TEST ) ) + + val resultMock: CompletableFuture = mockk() + val authMock: IAuthenticationResult = mockk() + every { resultMock.get() } returns authMock + every { authMock.metadata().tokenSource() } returns TokenSource.CACHE + every { authMock.accessToken() } returns "123" + every { securedApp.acquireToken(any()) } returns resultMock } @AfterEach diff --git a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandlingTest.kt b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandlingTest.kt index 6ebd077..b3779a9 100644 --- a/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandlingTest.kt +++ b/src/test/kotlin/com/swisscom/health/des/cdr/clientvm/handler/PullFileHandlingTest.kt @@ -1,5 +1,7 @@ package com.swisscom.health.des.cdr.clientvm.handler +import com.microsoft.aad.msal4j.ClientCredentialParameters +import com.microsoft.aad.msal4j.IConfidentialClientApplication import com.swisscom.health.des.cdr.clientvm.config.CdrClientConfig import io.micrometer.tracing.Span import io.micrometer.tracing.TraceContext @@ -21,6 +23,8 @@ import org.junit.jupiter.api.io.TempDir import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpStatus import org.springframework.http.MediaType +import org.springframework.retry.RetryCallback +import org.springframework.retry.support.RetryTemplate import java.nio.charset.StandardCharsets import java.nio.file.Path import java.util.UUID @@ -48,6 +52,15 @@ internal class PullFileHandlingTest { @MockK private lateinit var traceContext: TraceContext + @MockK + private lateinit var clientCredentialParams: ClientCredentialParameters + + @MockK + private lateinit var retryIoErrorsThrice: RetryTemplate + + @MockK + private lateinit var securedApp: IConfidentialClientApplication + @TempDir private lateinit var tmpDir: Path @@ -78,8 +91,11 @@ internal class PullFileHandlingTest { every { config.endpoint } returns endpoint every { config.localFolder } returns inflightDir every { config.functionKey } returns "1" + every { config.idpCredentials.tenantId } returns "something" + + every { retryIoErrorsThrice.execute(any>()) } returns "Mocked Result" - pullFileHandling = PullFileHandling(config, OkHttpClient.Builder().build(), tracer) + pullFileHandling = PullFileHandling(config, OkHttpClient.Builder().build(), clientCredentialParams, retryIoErrorsThrice, securedApp, tracer) } @AfterEach diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index a70f866..ce218e6 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,6 +1,17 @@ client: function-key: + idp-credentials: + # if you change this value, you must also change it in the mock-oauth2-server/config.json file and caddy/Caddyfile + tenant-id: test-tenant-id + client-id: test-client-id + client-secret: test-client-secret + scopes: + - https://graph.microsoft.com/.default local-folder: ${java.io.tmpdir}/cdr_download} + # if you change the issuer host name, you also need to change add in caddy/Caddyfile and you need to add the new host to the `test_hosts` file; + # no matter what you change it to, unless you use one of the trusted issuer hosts (com.microsoft.aad.msal4j.AadInstanceDiscoveryProvider.TRUSTED_HOSTS_SET), + # msal4j's initial request will go to `login.microsoftonline.com` to retrieve some MS specific metadata; see wiremock/mappings/discovery.json + idp-endpoint: https://login.microsoftonline.com:8443/${app-mgr.idp-credentials.tenant-id} schedule-delay: PT1S files-in-progress-cache-size: 1MB connection-timeout-ms: 500 diff --git a/src/test/resources/test_hosts b/src/test/resources/test_hosts new file mode 100644 index 0000000..9a006ec --- /dev/null +++ b/src/test/resources/test_hosts @@ -0,0 +1,4 @@ +127.0.0.1 localhost +# MSAL4J has those hosts hard-coded, which makes it complicated for testing; see com.microsoft.aad.msal4j.AadInstanceDiscoveryProvider.DEFAULT_TRUSTED_HOST +127.0.0.1 login.microsoftonline.com +# END MSAL4J