diff --git a/.checkstyle b/.checkstyle deleted file mode 100644 index d696148..0000000 --- a/.checkstyle +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/.forgejo/workflows/coverage-check.yaml b/.forgejo/workflows/coverage-check.yaml index 0681c8a..ecf8b44 100644 --- a/.forgejo/workflows/coverage-check.yaml +++ b/.forgejo/workflows/coverage-check.yaml @@ -31,20 +31,24 @@ jobs: # need to setup node and git - run: | apt-get update - apt-get install -y nodejs git curl maven + apt-get install -y nodejs git curl # check out code - uses: actions/checkout@v4 - uses: https://github.com/actions/setup-java@v4 with: distribution: "temurin" java-version: "21" + # ensure executable + - run: chmod +x ./gradlew # run gradle test - - run: mvn clean test jacoco:report + - run: ./gradlew check --no-daemon + env: + MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }} # process jacoco report - name: Collect coverage report id: coverage run: | - INPUT=$(grep -o ']*>' target/site/jacoco/jacoco.xml | tail -1) + INPUT=$(grep -o ']*>' build/reports/jacoco/test/jacocoTestReport.xml | tail -1) MISSED=$(echo "$INPUT" | sed -n 's/.*missed="\([0-9]*\)".*/\1/p') COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p') echo "MISSED: $MISSED" diff --git a/.forgejo/workflows/publish-docker.yaml b/.forgejo/workflows/publish-docker.yaml index e139111..c7151aa 100644 --- a/.forgejo/workflows/publish-docker.yaml +++ b/.forgejo/workflows/publish-docker.yaml @@ -51,6 +51,15 @@ jobs: with: distribution: "temurin" java-version: "21" + # ensure executable + - run: chmod +x ./gradlew + # run gradle test and build + # disabled since gradle doesn't require maven token anymore +# - run: | +# ./gradlew check +# ./gradlew bootJar +# env: +# MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }} # setup docker - name: set up docker cli run: | @@ -74,6 +83,7 @@ jobs: uses: docker/build-push-action@v6 with: context: . +# file: ci.Dockerfile file: Dockerfile push: true tags: | diff --git a/.gitignore b/.gitignore index d94278b..63d2f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,36 @@ -# Useful examples -# https://github.com/github/gitignore +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ -# OS X -*.DS_Store - -# Java -*.jar -*.war -*.ear - -# C/C++ -*.so -*.dylib -*.dSYM -*.dll -*.jnilib - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -# Directories -**/bin/ -**/classes/ -**/dist/ -**/include/ -**/nbproject/ -/.libs/ -/findbugs/ -/target/ - -#intellij -.idea/ -*.iml - -#logs -*.log - -#project specific -/src/genjava - -#Eclipse che -.che/ +### STS ### +.apt_generated .classpath +.factorypath .project -.settings/ +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile index ebe212c..49de41b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM maven:3 AS build +FROM gradle:jdk21 AS build WORKDIR /code COPY . /code -RUN mvn clean package spring-boot:repackage +RUN gradle bootJar --no-daemon FROM azul/zulu-openjdk:21-latest EXPOSE 8099 WORKDIR /app -COPY --from=build /code/target/*.jar /app/app.jar +COPY --from=build /code/build/libs/*.jar /app/app.jar ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..68ec74c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,128 @@ +plugins { + java + id("org.springframework.boot") version "3.5.3" + id("io.spring.dependency-management") version "1.1.7" + jacoco + checkstyle + idea +} + +group = "com.cleverthis.clevermicro" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +configurations.all { + // disable the cache, so it will fetch the latest snapshot for client api + resolutionStrategy.cacheChangingModulesFor(30, TimeUnit.SECONDS) + resolutionStrategy.cacheDynamicVersionsFor(30, TimeUnit.SECONDS) +} + +repositories { + mavenCentral() + maven { + name = "cleverthis-forgejo-maven" + url = uri("https://git.cleverthis.com/api/packages/clevermicro/maven") + // disable auth since artifacts are publicly available +// if (System.getenv("MAVEN_TOKEN").isNullOrBlank()) { +// credentials(HttpHeaderCredentials::class) { +// name = "Authorization" +// val token = +// findProperty("cleverThisForgejoToken") as String? ?: error("Token not found") +// value = "token $token" +// } +// } else { +// credentials(HttpHeaderCredentials::class) { +// name = "Authorization" +// val token = System.getenv("MAVEN_TOKEN") +// value = "token $token" +// } +// } +// authentication { +// create("header", HttpHeaderAuthentication::class) +// } + } +} + +extra["springCloudVersion"] = "2025.0.0" + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}") + } +} + +// https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +val mockitoAgent = configurations.create("mockitoAgent") +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.cloud:spring-cloud-starter-consul-config") + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + + // clevermicro + implementation("com.cleverthis.clevermicro:core:0.2.0-SNAPSHOT") + + // keycloak admin client + implementation("org.keycloak:keycloak-admin-client:26.0.6") + + // opentelemetry + implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.11.0")) + implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("io.projectreactor:reactor-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + mockitoAgent("org.mockito:mockito-core") { isTransitive = false } +} + +tasks.withType { + jvmArgs("-javaagent:${mockitoAgent.asPath}") + useJUnitPlatform() + // generate jacoco test report + finalizedBy("jacocoTestReport") + // show full output streams so we can debug for pipelines + testLogging { + showStandardStreams = true + } +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + } +} + +checkstyle { + toolVersion = "10.23.1" + configFile = file("${project.rootDir}/checkstyle.xml") + isShowViolations = true + isIgnoreFailures = false + maxWarnings = 0 + maxErrors = 0 + + sourceSets = setOf(project.sourceSets["main"]) +} + +idea { // tell IDEA to always download source code and javadoc + module { + isDownloadJavadoc = true + isDownloadSources = true + } +} diff --git a/ci.Dockerfile b/ci.Dockerfile new file mode 100644 index 0000000..bcaa474 --- /dev/null +++ b/ci.Dockerfile @@ -0,0 +1,5 @@ +FROM azul/zulu-openjdk:21-latest +EXPOSE 8099 +WORKDIR /app +COPY build/libs/*.jar /app/app.jar +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pom.xml b/pom.xml deleted file mode 100644 index 6f1bd84..0000000 --- a/pom.xml +++ /dev/null @@ -1,161 +0,0 @@ - - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 3.4.5 - - - com.cleverthis - auth-service - 0.0.1-SNAPSHOT - auth-service - User management project for CleverThis - - - - - - - - - - - - - - - 21 - com.cleverthis.authservice.AuthServiceApplication - 2024.0.1 - - - - - org.springframework.cloud - spring-cloud-dependencies - ${spring-cloud.version} - pom - import - - - - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-webflux - - - org.projectlombok - lombok - true - provided - - - org.springframework.boot - spring-boot-starter-test - test - - - io.projectreactor - reactor-test - test - - - org.springframework.cloud - spring-cloud-starter-consul-config - - - com.squareup.okhttp3 - mockwebserver - 4.12.0 - test - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - org.jacoco - jacoco-maven-plugin - 0.8.13 - - - - prepare-agent - - - - report - prepare-package - - report - - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - 3.6.0 - - - com.puppycrawl.tools - checkstyle - 10.23.1 - - - - checkstyle.xml - UTF-8 - true - true - warning - true - false - - - - validate - validate - - check - - - - - - - - diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9ebc9cf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "auth-service" diff --git a/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfig.java b/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfig.java new file mode 100644 index 0000000..0b62c52 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfig.java @@ -0,0 +1,89 @@ +package com.cleverthis.authservice.config; + +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpConfig; +import com.rabbitmq.client.ConnectionFactory; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +/** + * Provide {@link CleverMicroAmqpClient} as a bean. + */ +@Configuration +@EnableConfigurationProperties(CleverMicroClientConfigProperties.class) +@Slf4j +public class CleverMicroClientConfig { + + private final CleverMicroClientConfigProperties properties; + + public CleverMicroClientConfig( + final CleverMicroClientConfigProperties configProperties + ) { + this.properties = configProperties; + } + + /** + * Post construct. + */ + @PostConstruct + public void init() { + // correct availability + if (this.properties.getInitialAvailability() <= 0) { + this.properties.setInitialAvailability(Runtime.getRuntime().availableProcessors() * 2); + } + log.info("CleverMicroClientConfig initial availability: {}", + this.properties.getInitialAvailability()); + log.info("CleverMicroClient will use swarm info: serviceId={}, taskId={}", + this.properties.getSwarmServiceId(), this.properties.getSwarmTaskId()); + } + + /** + * Provide a RabbitMQ {@link ConnectionFactory}. + */ + @Bean + @Lazy + public ConnectionFactory connectionFactory() { + final var factory = new ConnectionFactory(); + factory.setHost(this.properties.getRabbitmqHost()); + factory.setPort(this.properties.getRabbitmqPort()); + factory.setUsername(this.properties.getRabbitmqUsername()); + factory.setPassword(this.properties.getRabbitmqPassword()); + factory.setVirtualHost(this.properties.getRabbitmqVhost()); + factory.setAutomaticRecoveryEnabled(true); + return factory; + } + + /** + * Create a {@link CleverMicroAmqpClient} bean. + */ + @Bean + @Lazy + @ConditionalOnMissingBean + public CleverMicroAmqpClient cleverMicroAmqpClient( + final ConnectionFactory connectionFactory, + final ApplicationContext applicationContext + ) throws IOException, TimeoutException { + final var client = new CleverMicroAmqpClient( + connectionFactory.newConnection(), CleverMicroAmqpConfig.builder() + .serviceName(applicationContext.getApplicationName()) + .swarmServiceId(this.properties.getSwarmServiceId()) + .swarmTaskId(this.properties.getSwarmTaskId()) + .reportAvailabilityUsageCooldown(Duration.ofMillis(250)) + .reportAvailabilityRecoveryCooldown(Duration.ofSeconds(1)) + .reportAvailabilityRegularCooldown(Duration.ofSeconds(5)) + .build() + ); + client.getHandlerManager().getAvailability() + .release(this.properties.getInitialAvailability()); + return client; + } +} diff --git a/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfigProperties.java b/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfigProperties.java new file mode 100644 index 0000000..b3903a3 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/config/CleverMicroClientConfigProperties.java @@ -0,0 +1,65 @@ +package com.cleverthis.authservice.config; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Config properties for {@link CleverMicroClientConfig}. + */ +@ConfigurationProperties(prefix = "clevermicro.client") +@Builder +@Getter +@Setter +public class CleverMicroClientConfigProperties { + /** + * Host of the rabbitmq server. + */ + @Builder.Default + private String rabbitmqHost = "localhost"; + + /** + * The port of the rabbitmq server. + */ + @Builder.Default + private int rabbitmqPort = 5672; + + /** + * RabbitMQ username. + */ + @Builder.Default + private String rabbitmqUsername = "guest"; + + /** + * RabbitMQ password. + */ + @Builder.Default + private String rabbitmqPassword = "guest"; + + /** + * Vhost of the rabbitmq server. + */ + @Builder.Default + private String rabbitmqVhost = "/"; + + /** + * Initial availability. + *

+ * Default: CPU cores * 2, since the workload for auth-service is mainly IO-intensive. + *

+ *

Set to -1 to use the default value.

+ * */ + @Builder.Default + private int initialAvailability = Runtime.getRuntime().availableProcessors() * 2; + + /** + * Docker swarm service id. + */ + private String swarmServiceId; + + /** + * Docker swarm task id. + */ + private String swarmTaskId; +} diff --git a/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java index fcd73dd..cb328da 100644 --- a/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java +++ b/src/main/java/com/cleverthis/authservice/config/KeycloakClientConfig.java @@ -1,15 +1,37 @@ package com.cleverthis.authservice.config; +import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient; +import org.keycloak.admin.client.Keycloak; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; /** * Config for keycloak clients: * {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService} - * and {@link com.cleverthis.authservice.service.impl.KeycloakAdminClient}. + * and {@link KeycloakAdminReactiveClient}. */ @Configuration @EnableConfigurationProperties(KeycloakClientConfigProperties.class) public class KeycloakClientConfig { - // empty for now + + /** + * Create a Keycloak admin client. + * */ + @Bean + @Lazy + @ConditionalOnMissingBean + public Keycloak keycloakAdminClient( + final KeycloakClientConfigProperties configProperties + ) { + return Keycloak.getInstance( + configProperties.getKeycloakAdminHost(), + configProperties.getRealm(), + configProperties.getAdminAccountUsername(), + configProperties.getAdminAccountPassword(), + "admin-cli" + ); + } } diff --git a/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java b/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java index 4f2598b..025afa1 100644 --- a/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java +++ b/src/main/java/com/cleverthis/authservice/config/PermissionMappingConfigProperties.java @@ -40,6 +40,7 @@ public class PermissionMappingConfigProperties { @NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule") private String keycloakClientId; + @Builder.Default private List passthroughs = new ArrayList<>(); } diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java index 90a1804..2046d0e 100644 --- a/src/main/java/com/cleverthis/authservice/controller/AuthController.java +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -187,17 +187,19 @@ public class AuthController { log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod, originalUriString); // first detect the client id and service relative path - final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString); - if (targetServiceClientIdOpt.isEmpty()) { + final var optionalRule = getPermissionCheckRuleByUri(originalUriString); + if (optionalRule.isEmpty()) { log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}", originalUriString); return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build()); } - final var targetServiceClientId = targetServiceClientIdOpt.get(); + final var rule = optionalRule.get(); + + final var targetServiceClientId = rule.getKeycloakClientId(); final var serviceRelativePath = - extractServiceRelativePath(originalUriString, targetServiceClientId); + extractServiceRelativePath(originalUriString, rule); // then check pass-through rule - if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) { + if (shouldPassThrough(rule, serviceRelativePath)) { return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build()); } if (authorizationHeader == null) { @@ -276,16 +278,15 @@ public class AuthController { } private boolean shouldPassThrough( - final String serviceClientId, final String serviceRelativePath + final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath ) { - final var rules = this.permissionMapLookupService - .getPassThoughRulesByClientId(serviceClientId); + final var passthroughs = rule.getPassthroughs(); try { - return rules.stream() + return passthroughs.stream() .anyMatch(it -> executePassThroughRule(it, serviceRelativePath)); } catch (final Throwable throwable) { log.error("Error executing pass-through rule for client {} : {}", - serviceClientId, throwable.getMessage(), throwable); + rule.getKeycloakClientId(), throwable.getMessage(), throwable); return false; } } @@ -300,13 +301,15 @@ public class AuthController { }; } - private Optional getTargetServiceClientId(String fullUriString) { + private Optional getPermissionCheckRuleByUri( + String fullUriString + ) { try { URI uri = new URI(fullUriString); String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123 log.debug("Mapping URI path: {}", path); - final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path); + final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path); if (pathMatch.isPresent()) { return pathMatch; } @@ -315,7 +318,9 @@ public class AuthController { log.debug( "No rule matched for path '{}', using default Keycloak Client ID:{}", path, defaultValue); - return Optional.of(defaultValue); + return Optional.of(PermissionMappingConfigProperties.Rule.builder() + .keycloakClientId(defaultValue) + .build()); } } catch (URISyntaxException e) { log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e); @@ -324,17 +329,11 @@ public class AuthController { return Optional.empty(); } - private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) { - // Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix - Optional matchedRule = - this.permissionMapLookupService.matchRuleByClientIdAndPath( - mappedKeycloakClientId, fullUriString - ); - - String pathPrefixToRemove = ""; - if (matchedRule.isPresent()) { - pathPrefixToRemove = matchedRule.get().getPathPrefix(); - } else if (mappedKeycloakClientId + private String extractServiceRelativePath( + String fullUriString, PermissionMappingConfigProperties.Rule rule + ) { + String pathPrefixToRemove = rule.getPathPrefix(); + if (rule.getKeycloakClientId() .equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) { // If it's a default mapping, there's no prefix to remove, use the whole path. // Or, you might decide that default mappings always apply to root paths. This diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java new file mode 100644 index 0000000..2348792 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpoint.java @@ -0,0 +1,148 @@ +package com.cleverthis.authservice.controller.rabbitmq; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.BuiltinExchangeType; +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Abstract class for endpoint of auth-service over RabbitMQ. + */ +@Slf4j +public abstract class AbstractEndpoint implements AutoCloseable { + public static final String ENDPOINT_EXCHANGE = "cleverthis.clevermicro.auth.endpoints"; + public static final BuiltinExchangeType ENDPOINT_EXCHANGE_TYPE = BuiltinExchangeType.DIRECT; + + private final String handlerTag; + private final CleverMicroAmqpClient client; + protected final ObjectMapper objectMapper; + + private final String queueName; + + /** + * Initialize this abstract class. + */ + public AbstractEndpoint( + final CleverMicroAmqpClient client, + final ObjectMapper objectMapper, + final String routingKey + ) throws IOException { + this.client = client; + this.objectMapper = objectMapper; + + this.queueName = ENDPOINT_EXCHANGE + "." + routingKey; + client.getHandlerManager().declareExchange(ENDPOINT_EXCHANGE, ENDPOINT_EXCHANGE_TYPE); + client.getHandlerManager().declareQueue( + this.queueName, true, false, false, Map.of() + ); + client.getHandlerManager().bindQueue( + this.queueName, ENDPOINT_EXCHANGE, routingKey, Map.of() + ); + this.handlerTag = client.getHandlerManager().registerHandler( + this.queueName, "", 1, + ctx -> new Thread(() -> handleRabbitMessage(ctx)).start(), + tag -> log.info("Endpoint canceled for queue {}: handlerTag={}", + this.queueName, tag) + ); + } + + // VisibleForTesting + void handleRabbitMessage(final HandlerContext ctx) { + try { + final var req = parseRequest(ctx); + if (req == null) { + throw new EndpointBadRequestException( + "Malformed request, cannot be parsed"); + } + handleRequest(ctx, req); + } catch (final EndpointException ex) { + final var resp = EndpointResponses.error(ex); + reply(ctx, resp); + } catch (final Throwable t) { + log.error( + "Error when handling request for queue {}, only refill availability", + this.queueName, t + ); + final var resp = EndpointResponses.internalServerError( + "Error when handling the request", t); + reply(ctx, resp); + } + } + + + // VisibleForTesting + EndpointRequest parseRequest(final HandlerContext ctx) { + InputStream reqStream = null; + if (ctx.getMessageBodySingleStream().isPresent()) { + reqStream = ctx.getMessageBodySingleStream().get(); + } else if (ctx.getMessageBodyMultiStream().isPresent()) { + final var multibody = ctx.getMessageBodyMultiStream().get(); + reqStream = multibody.get("request"); + } + // check null + if (reqStream == null) { + return null; + } + // parsing, need to convert to final + try (final var stream = reqStream) { + return this.objectMapper.readValue( + stream, + EndpointRequest.class + ); + } catch (final IOException ex) { + log.error("Error when parsing single stream request for queue: {}", this.queueName, ex); + return null; + } + } + + /** + * Handle a request. + */ + protected abstract void handleRequest( + final HandlerContext ctx, + final EndpointRequest request + ) throws EndpointException; + + /** + * Try to reply. Refill counter if error. + */ + protected void reply(final HandlerContext ctx, final Object response) { + final byte[] bytes; + try { + bytes = this.objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsBytes(response); + } catch (final JsonProcessingException ex) { + log.error("Error when serializing response, ignore reply and refill counter. Queue {}", + this.queueName, ex); + ctx.refillAvailability(); + return; + } + try { + ctx.finish(bytes); + } catch (final IOException ex) { + // the counter should be refilled before exception + log.error("Error when replying request for queue {}", this.queueName, ex); + } + } + + /** + * Close the endpoint and release resources. + * Should be called in {@link jakarta.annotation.PreDestroy} annotated methods. + */ + @Override + @PreDestroy + public void close() { + log.info("Closing Endpoint for queue: {}, consumerTag={}", this.queueName, this.handlerTag); + this.client.getHandlerManager().cancelHandler(this.handlerTag); + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointBadRequestException.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointBadRequestException.java new file mode 100644 index 0000000..33052a3 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointBadRequestException.java @@ -0,0 +1,21 @@ +package com.cleverthis.authservice.controller.rabbitmq.exception; + +/** + * Exception to signal the request is malformed or invalid. + */ +public class EndpointBadRequestException extends EndpointException { + private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.bad_request"; + + public EndpointBadRequestException( + final String message, + final Throwable cause + ) { + super(ENDPOINT_EXCEPTION, message, cause); + } + + public EndpointBadRequestException( + final String message + ) { + super(ENDPOINT_EXCEPTION, message); + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointException.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointException.java new file mode 100644 index 0000000..bd34665 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointException.java @@ -0,0 +1,28 @@ +package com.cleverthis.authservice.controller.rabbitmq.exception; + +import lombok.Getter; + +/** + * Exception for endpoints. + */ +public class EndpointException extends Exception { + @Getter + private final String endpointException; + + public EndpointException( + final String endpointException, + final String message, + final Throwable cause + ) { + super(message, cause); + this.endpointException = endpointException; + } + + public EndpointException( + final String endpointException, + final String message + ) { + super(message); + this.endpointException = endpointException; + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointInternalServerErrorException.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointInternalServerErrorException.java new file mode 100644 index 0000000..95cba49 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/exception/EndpointInternalServerErrorException.java @@ -0,0 +1,15 @@ +package com.cleverthis.authservice.controller.rabbitmq.exception; + +/** + * Exception to signal the handler is failed abnormally. + */ +public class EndpointInternalServerErrorException extends EndpointException { + private static final String ENDPOINT_EXCEPTION = "cleverthis.clevermicro.server_internal_error"; + + public EndpointInternalServerErrorException( + final String message, + final Throwable cause + ) { + super(ENDPOINT_EXCEPTION, message, cause); + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointErrorResponse.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointErrorResponse.java new file mode 100644 index 0000000..b0e99f0 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointErrorResponse.java @@ -0,0 +1,39 @@ +package com.cleverthis.authservice.controller.rabbitmq.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * The model for replied response. + */ +@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString +@Builder +public final class EndpointErrorResponse extends EndpointResponse { + @JsonProperty("exception") + private final String exception; + @JsonProperty("message") + private final String message; + @JsonProperty("additional_info") + private final Object additionalInfo; + + /** + * Create an error response. + */ + public EndpointErrorResponse( + final String exception, + final String message, + final Object additionalInfo + ) { + super("ERROR"); + this.exception = exception; + this.message = message; + this.additionalInfo = additionalInfo; + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointOkResponse.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointOkResponse.java new file mode 100644 index 0000000..8aadf33 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointOkResponse.java @@ -0,0 +1,27 @@ +package com.cleverthis.authservice.controller.rabbitmq.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * The model for replied response. + */ +@EqualsAndHashCode(callSuper = true) +@Getter +@Setter +@ToString +@Builder +public class EndpointOkResponse extends EndpointResponse { + @JsonProperty("result") + private final List result; + + public EndpointOkResponse(final List result) { + super("OK"); + this.result = result; + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointRequest.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointRequest.java new file mode 100644 index 0000000..39bfe92 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointRequest.java @@ -0,0 +1,19 @@ +package com.cleverthis.authservice.controller.rabbitmq.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.LinkedHashMap; +import lombok.Builder; +import lombok.Data; + +/** + * The model for incoming requests. + */ +@Data +@Builder +public class EndpointRequest { + @JsonProperty("method") + private final String method; + @JsonProperty("args") + private final LinkedHashMap args; +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponse.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponse.java new file mode 100644 index 0000000..a8027c5 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponse.java @@ -0,0 +1,19 @@ +package com.cleverthis.authservice.controller.rabbitmq.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +/** + * The model for replied response. + */ +@Getter +@Setter +public abstract class EndpointResponse { + @JsonProperty("type") + protected final String type; + + protected EndpointResponse(final String type) { + this.type = type; + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponses.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponses.java new file mode 100644 index 0000000..c0e2ab6 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/model/EndpointResponses.java @@ -0,0 +1,84 @@ +package com.cleverthis.authservice.controller.rabbitmq.model; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointInternalServerErrorException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * The model for replied response. + */ +public class EndpointResponses { + private EndpointResponses() { + } + + /** + * Create an error response from endpoint exception. + */ + public static EndpointErrorResponse error( + final EndpointException ex + ) { + final var sw = new StringWriter(); + final var pw = new PrintWriter(sw); + ex.printStackTrace(pw); + pw.flush(); + pw.close(); + return EndpointErrorResponse.builder() + .exception(ex.getEndpointException()) + .message(ex.getMessage()) + .additionalInfo(Map.of( + "stacktrace", sw.toString() + )) + .build(); + } + + /** + * Create an error response from general exception. + */ + public static EndpointErrorResponse internalServerError( + final String message, + final Throwable throwable + ) { + return EndpointResponses.error( + new EndpointInternalServerErrorException( + message, throwable + ) + ); + } + + /** + * Create an ok response from a result. + */ + public static EndpointOkResponse ok(final T result) { + return EndpointOkResponse.builder() + .result(List.of(result)) + .build(); + } + + /** + * Create an ok response from a list of results. + */ + public static EndpointOkResponse ok(final Iterable result) { + final var list = new LinkedList(); + result.forEach(list::add); + return EndpointOkResponse.builder() + .result(list) + .build(); + } + + /** + * Create an ok response from an array of results. + */ + public static EndpointOkResponse ok(final T[] result) { + // here we make a copy so the changes to the input array + // will not change the result field. + final var list = new LinkedList<>(Arrays.asList(result)); + return EndpointOkResponse.builder() + .result(list) + .build(); + } +} diff --git a/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java new file mode 100644 index 0000000..b8b8a56 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1.java @@ -0,0 +1,70 @@ +package com.cleverthis.authservice.controller.rabbitmq.v1.user; + +import com.cleverthis.authservice.controller.rabbitmq.AbstractEndpoint; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses; +import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.NoSuchElementException; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.stereotype.Component; + +/** + * Serve endpoints on `user-service-v1`. + */ +@Component +public class UserEndpointV1 extends AbstractEndpoint { + private static final String ROUTING_KEY = "user-service-v1"; + + private final KeycloakAdminReactiveClient keycloakAdminClient; + + public UserEndpointV1( + final CleverMicroAmqpClient client, + final ObjectMapper objectMapper, + final KeycloakAdminReactiveClient keycloakAdminClient + ) throws IOException { + super(client, objectMapper, ROUTING_KEY); + this.keycloakAdminClient = keycloakAdminClient; + } + + @Override + protected void handleRequest( + final HandlerContext ctx, final EndpointRequest request + ) throws EndpointException { + //noinspection SwitchStatementWithTooFewBranches + final Object result = switch (request.getMethod()) { + case "queryUserById" -> queryUserById(request); + default -> throw new EndpointBadRequestException("Method not found"); + }; + reply(ctx, EndpointResponses.ok(result)); + } + + private UserRepresentation queryUserById( + final EndpointRequest request + ) throws EndpointException { + final var argId = request.getArgs().get("id"); + if (argId == null) { + throw new EndpointBadRequestException("Missing parameter 'id'"); + } + if (!argId.isTextual()) { + throw new EndpointBadRequestException( + "Invalid type for parameter 'id', required non-null string"); + } + // this should be non-null + final var id = argId.textValue(); + try { + return this.keycloakAdminClient.getUserDetails(id).block(); + } catch (final NoSuchElementException ex) { + throw new EndpointException( + "cleverthis.clevermicro.auth.user_not_found", + "User id " + id + "not found", + ex + ); + } + } +} diff --git a/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/KeycloakOrganizationRepresentation.java b/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/KeycloakOrganizationRepresentation.java index 3d2310b..29feb3c 100644 --- a/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/KeycloakOrganizationRepresentation.java +++ b/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/KeycloakOrganizationRepresentation.java @@ -8,6 +8,7 @@ import java.util.Set; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; /** * Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak diff --git a/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/OrganizationDomainRepresentation.java b/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/OrganizationDomainRepresentation.java deleted file mode 100644 index 05b8c74..0000000 --- a/src/main/java/com/cleverthis/authservice/dto/keycloakadmin/OrganizationDomainRepresentation.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.cleverthis.authservice.dto.keycloakadmin; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Representation of an organization's internet domain within Keycloak. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class OrganizationDomainRepresentation { - private String name; - private Boolean verified; -} diff --git a/src/main/java/com/cleverthis/authservice/dto/organization/OrganizationResponse.java b/src/main/java/com/cleverthis/authservice/dto/organization/OrganizationResponse.java index 717f3c2..6ccec9f 100644 --- a/src/main/java/com/cleverthis/authservice/dto/organization/OrganizationResponse.java +++ b/src/main/java/com/cleverthis/authservice/dto/organization/OrganizationResponse.java @@ -1,19 +1,19 @@ package com.cleverthis.authservice.dto.organization; -import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation; import java.util.List; import java.util.Map; import java.util.Set; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; /** * DTO for representing an Organization in API responses. */ @Data @NoArgsConstructor -@AllArgsConstructor +@AllArgsConstructor public class OrganizationResponse { private String id; diff --git a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java index 691dcca..4f97e5f 100644 --- a/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java +++ b/src/main/java/com/cleverthis/authservice/service/IdentityManagerService.java @@ -12,6 +12,8 @@ import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse; import com.cleverthis.authservice.dto.organization.OrganizationResponse; import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest; import java.util.List; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; import reactor.core.publisher.Mono; /** diff --git a/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java b/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java index 6ad18ef..0037b60 100644 --- a/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java +++ b/src/main/java/com/cleverthis/authservice/service/PermissionMapLookupService.java @@ -1,7 +1,6 @@ package com.cleverthis.authservice.service; import com.cleverthis.authservice.config.PermissionMappingConfigProperties; -import java.util.List; import java.util.Optional; /** @@ -17,18 +16,5 @@ public interface PermissionMapLookupService { /** * Given the path, find the client id of the first rule matching the path prefix. */ - Optional matchClientIdByPath(String path); - - /** - * Given the client id and path, find the rule that both matches the client id exactly, - * and match the path prefix. - */ - Optional matchRuleByClientIdAndPath( - String clientId, String path); - - /** - * Return the list of pass-through rules by client id. - */ - List getPassThoughRulesByClientId( - String clientId); + Optional matchRuleByPath(String path); } diff --git a/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java b/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java index 4e6ffbc..ae2ef21 100644 --- a/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java +++ b/src/main/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupService.java @@ -5,7 +5,6 @@ import com.cleverthis.authservice.service.PermissionMapLookupService; import com.ecwid.consul.v1.ConsulClient; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import lombok.extern.slf4j.Slf4j; @@ -102,38 +101,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo } @Override - public Optional matchClientIdByPath(final String path) { + public Optional matchRuleByPath(final String path) { final var config = getConfig(); for (final var rule : config.getRules()) { if (path.startsWith(rule.getPathPrefix())) { log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}", path, rule.getPathPrefix(), rule.getKeycloakClientId()); - return Optional.of(rule.getKeycloakClientId()); + return Optional.of(rule); } } return Optional.empty(); } - - @Override - public Optional matchRuleByClientIdAndPath( - final String clientId, final String path - ) { - final var config = getConfig(); - return config.getRules().stream() - .filter(rule -> - rule.getKeycloakClientId().equals(clientId) - && path.startsWith(rule.getPathPrefix())) - .findFirst(); - } - - @Override - public List getPassThoughRulesByClientId( - final String clientId - ) { - final var config = getConfig(); - return config.getRules().stream() - .filter(rule -> rule.getKeycloakClientId().equals(clientId)) - .flatMap(rule -> rule.getPassthroughs().stream()) - .toList(); - } } diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java deleted file mode 100644 index 6384d3f..0000000 --- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminClient.java +++ /dev/null @@ -1,531 +0,0 @@ -package com.cleverthis.authservice.service.impl; - -import com.cleverthis.authservice.config.KeycloakClientConfigProperties; -import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse; -import com.cleverthis.authservice.dto.KeycloakClientRepresentation; -import com.cleverthis.authservice.dto.KeycloakRoleRepresentation; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation; -import com.cleverthis.authservice.exception.ServiceUnavailableException; -import java.net.URI; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.util.UriComponentsBuilder; -import reactor.core.publisher.Mono; -import reactor.util.retry.Retry; - -/** - * Service to interact with Keycloak Admin API. Handles obtaining an admin token for this - * auth-service and making admin calls. - */ -@Service -@Slf4j -public class KeycloakAdminClient { - - private final WebClient webClient; - private final AtomicReference adminAccessToken = new AtomicReference<>(); - private final AtomicReference tokenExpiryTime = new AtomicReference<>(0L); - - private final String keycloakAdminHost; - private final String realm; - private final String adminAccountUsername; - private final String adminAccountPassword; - - /** - * Create a keycloak admin client. - */ - public KeycloakAdminClient( - @Qualifier("keycloakWebClient") final WebClient webClient, - final KeycloakClientConfigProperties configProperties - ) { - this.webClient = webClient; - this.keycloakAdminHost = configProperties.getKeycloakAdminHost(); - this.realm = configProperties.getRealm(); - this.adminAccountUsername = configProperties.getAdminAccountUsername(); - this.adminAccountPassword = configProperties.getAdminAccountPassword(); - } - - private String getAdminTokenEndpoint() { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/realms/{realm}/protocol/openid-connect/token") - .buildAndExpand(this.realm) - .encode().toUriString(); - } - - private String getClientsEndpoint() { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/clients") - .buildAndExpand(this.realm) - .encode().toUriString(); - } - - private String getUserClientRolesEndpoint(String userId, String clientInternalId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/users/{userId}" - + "/role-mappings/clients/{clientId}/composite") - .buildAndExpand(this.realm, this.realm, userId, clientInternalId) - .encode().toUriString(); - } - - private String getOrganizationByIdEndpoint(String orgId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/organizations/{orgId}") - .buildAndExpand(this.realm, orgId) - .encode().toUriString(); - } - - private String getOrganizationsEndpoint() { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/organizations") - .buildAndExpand(this.realm) - .encode().toUriString(); - } - - private String getOrganizationMembersEndpoint(String orgId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/organizations/{orgId}/members") - .buildAndExpand(this.realm, orgId) - .encode().toUriString(); - } - - private String getOrganizationMemberByIdEndpoint(String orgId, String userId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/organizations/{orgId}/members/{userId}") - .buildAndExpand(this.realm, orgId, userId) - .encode().toUriString(); - } - - private String getOrganizationInvitationEndpoint(String orgId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/organizations/{orgId}/members/invite-user") - .buildAndExpand(this.realm, orgId) - .encode().toUriString(); - } - - private String getGroupsEndpoint() { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/groups") - .buildAndExpand(this.realm).toUriString(); - } - - private String getGroupByIdEndpoint(String groupId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/groups/{groupId}") - .buildAndExpand(this.realm, groupId).encode().toUriString(); - } - - private String getGroupChildrenEndpoint(String parentGroupId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/groups/{parentGroupId}/children") - .buildAndExpand(this.realm, parentGroupId) - .encode().toUriString(); - } - - private String getGroupMembersEndpoint(String groupId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/groups/{groupId}/members") - .buildAndExpand(this.realm, groupId).encode() - .toUriString(); - } - - private String getGroupMemberByIdEndpoint(String groupId, String userId) { - return UriComponentsBuilder.fromUriString(this.keycloakAdminHost) - .path("/admin/realms/{realm}/users/{userId}/groups/{groupId}") - .buildAndExpand(this.realm, userId, groupId).encode().toUriString(); - } - - /** - * Retrieves a valid admin access token for this service, refreshing if necessary. Uses client - * credentials grant. - * - * @return Mono emitting the admin access token. - */ - Mono getAdminAccessToken() { - if (System.currentTimeMillis() < this.tokenExpiryTime.get() - && this.adminAccessToken.get() != null) { - log.debug("Using cached admin access token."); - return Mono.just(this.adminAccessToken.get()); - } - - log.info("Fetching new admin access token for auth-service using account: {}", - this.adminAccountUsername); - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("client_id", "admin-cli"); - formData.add("username", this.adminAccountUsername); - formData.add("password", this.adminAccountPassword); - formData.add("grant_type", "password"); - - return this.webClient.post().uri(getAdminTokenEndpoint()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)).retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - response -> response.bodyToMono(String.class).flatMap(errorBody -> { - log.error("Failed to get admin token. Status: {}, Body: {}", - response.statusCode(), errorBody); - return Mono.error(new ServiceUnavailableException( - "Could not obtain admin token from Keycloak. Status: " - + response.statusCode())); - })) - .bodyToMono(KeycloakAdminTokenResponse.class).map(tokenResponse -> { - this.adminAccessToken.set(tokenResponse.getAccessToken()); - // Set expiry time slightly before actual expiry to be safe (e.g., 30 seconds - // buffer) - this.tokenExpiryTime.set(System.currentTimeMillis() - + (tokenResponse.getExpiresIn() - 30) * 1000L); - log.info("Successfully obtained new admin access token."); - return tokenResponse.getAccessToken(); - }) - .retryWhen(Retry.backoff(3, Duration.ofSeconds(2)) - .maxBackoff(Duration.ofSeconds(10)) - .filter(throwable -> throwable instanceof ServiceUnavailableException) - .doBeforeRetry( - retrySignal -> log.warn("Retrying admin token fetch attempt #{}", - retrySignal.totalRetries() + 1))); - } - - /** - * Fetches the internal UUID of a Keycloak client given its public clientId. - * - * @param publicClientId The human-readable client_id (e.g., "cleverswarm-client"). - * @return Mono emitting the internal UUID string. - */ - public Mono getClientInternalId(String publicClientId) { - log.debug("Fetching internal ID for client: {}", publicClientId); - - // We expect only one client with this ID - URI targetUri = UriComponentsBuilder.fromUriString(getClientsEndpoint()) - .queryParam("clientId", publicClientId).queryParam("max", 1).build().toUri(); - - log.debug("Constructed URI for getClientInternalId: {}", targetUri.toString()); - - return getAdminAccessToken().flatMap(token -> this.webClient.get().uri(targetUri) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - response -> response.bodyToMono(String.class).flatMap(errorBody -> { - log.error("Failed to get client '{}'. Status: {}, Body: {}", - publicClientId, response.statusCode(), errorBody); - return Mono.error(new ServiceUnavailableException( - "Could not fetch client details from Keycloak. Client: " - + publicClientId)); - })) - .bodyToFlux(KeycloakClientRepresentation.class)// Expecting a list, even if one item - .next() // Take the first element if present - .map(KeycloakClientRepresentation::getId) - .switchIfEmpty(Mono.error(new ServiceUnavailableException( - "Client not found in Keycloak: " + publicClientId))) - .doOnSuccess(id -> log.debug("Found internal ID '{}' for client '{}'", id, - publicClientId))); - } - - /** - * Fetches the effective client roles for a given user and a specific client (internal ID). - * - * @param userId the Keycloak user's 'sub' (subject ID). - * @param clientInternalId The internal UUID of the Keycloak client. - * @return Mono emitting a List of KeycloakRoleRepresentation. - */ - public Mono> getUserEffectiveClientRoles( - String userId, String clientInternalId - ) { - log.debug("Fetching effective client roles for user '{}' on client internal ID '{}'", - userId, clientInternalId); - return getAdminAccessToken().flatMap(token -> this.webClient.get() - .uri(getUserClientRolesEndpoint(userId, clientInternalId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - response -> response.bodyToMono(String.class).flatMap(errorBody -> { - log.error( - "Failed to get user client roles. User:" - + " {}, ClientInternalId: {}, Status: {}, Body: {}", - userId, clientInternalId, response.statusCode(), errorBody); - return Mono.error(new ServiceUnavailableException( - "Could not fetch user client roles from Keycloak.")); - })) - .bodyToFlux(KeycloakRoleRepresentation.class).collectList() - .doOnSuccess(roles -> log.debug( - "Found {} effective client roles for user '{}' on client internal ID '{}'", - roles.size(), userId, clientInternalId))); - } - - - /** - * Creates a new organization in Keycloak. - * - * @param orgToCreate A representation of the organization to be created. - * @return A Mono emitting the representation of the newly created organization, including its - * ID. - */ - public Mono createOrganization( - KeycloakOrganizationRepresentation orgToCreate) { - return getAdminAccessToken() - .flatMap(token -> this.webClient.post().uri(getOrganizationsEndpoint()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON).bodyValue(orgToCreate).retrieve() - // Add error handling here for 409 Conflict, etc. - .bodyToMono(KeycloakOrganizationRepresentation.class)); - } - - /** - * Retrieves all organizations within the realm. - * - * @return A Mono emitting a List of all organization representations. - */ - public Mono> getOrganizations() { - return getAdminAccessToken() - .flatMap(token -> this.webClient.get().uri(getOrganizationsEndpoint()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .bodyToFlux(KeycloakOrganizationRepresentation.class).collectList()); - } - - /** - * Retrieves a single organization by its unique ID. - * - * @param orgId The ID of the organization to retrieve. - * @return A Mono emitting the representation of the found organization. - */ - public Mono getOrganizationById(String orgId) { - return getAdminAccessToken() - .flatMap(token -> this.webClient.get().uri(getOrganizationByIdEndpoint(orgId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .bodyToMono(KeycloakOrganizationRepresentation.class)); - } - - /** - * Updates an existing organization's details in Keycloak. - * - * @param orgId The ID of the organization to update. - * @param orgToUpdate A representation containing the updated fields for the organization. - * @return A Mono that completes when the update is successful. - */ - public Mono updateOrganization(String orgId, - KeycloakOrganizationRepresentation orgToUpdate) { - return getAdminAccessToken() - .flatMap(token -> this.webClient.put().uri(getOrganizationByIdEndpoint(orgId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON).bodyValue(orgToUpdate).retrieve() - .bodyToMono(Void.class)); - } - - /** - * Deletes an organization from Keycloak. - * - * @param orgId The ID of the organization to delete. - * @return A Mono that completes when the deletion is successful. - */ - public Mono deleteOrganization(String orgId) { - return getAdminAccessToken() - .flatMap(token -> this.webClient.delete().uri(getOrganizationByIdEndpoint(orgId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .bodyToMono(Void.class)); - } - - /** - * Sends an invitation for a user to join an organization. Keycloak handles the email delivery. - * - * @param orgId The ID of the organization to invite the user to. - * @param invitation An object containing the invitee's details (email, name). - * @return A Mono that completes when the invitation has been successfully sent. - */ - public Mono inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) { - // Create form data from the invitation DTO - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("email", invitation.getEmail()); - if (invitation.getFirstName() != null) { - formData.add("firstName", invitation.getFirstName()); - } - if (invitation.getLastName() != null) { - formData.add("lastName", invitation.getLastName()); - } - - return getAdminAccessToken().flatMap(token -> this.webClient.post() - .uri(getOrganizationInvitationEndpoint(orgId)) // Points to /invitations - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - // UPDATED: Use FORM_URLENCODED content type - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - // UPDATED: Use fromFormData to send the body - .body(BodyInserters.fromFormData(formData)).retrieve().bodyToMono(Void.class)); - } - - /** - * Retrieves a list of all members of a specific organization. - * - * @param orgId The ID of the organization. - * @return A Mono emitting a List of user representations for the members. - */ - public Mono> getOrganizationMembers(String orgId) { - return getAdminAccessToken() - .flatMap(token -> this.webClient.get().uri(getOrganizationMembersEndpoint(orgId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .bodyToFlux(KeycloakUserRepresentation.class).collectList()); - } - - /** - * Adds an existing Keycloak user to an organization. - * - * @param orgId The ID of the organization. - * @param userId The ID of the user to add as a member. - * @return A Mono that completes when the user is successfully added. - */ - public Mono addMemberToOrganization(String orgId, String userId) { - // Keycloak's API to add an existing user to an Organization is a POST - // to the /members collection endpoint, with the user's ID in the body. - return getAdminAccessToken().flatMap(token -> this.webClient.post() - // Use the endpoint for the members collection, NOT the specific member - .uri(getOrganizationMembersEndpoint(orgId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON) - // The body should be a representation containing the user's ID - .bodyValue(userId).retrieve().bodyToMono(Void.class)); - } - - /** - * Removes a member from an organization. - * - * @param orgId The ID of the organization. - * @param userId The ID of the user to remove. - * @return A Mono that completes when the user is successfully removed. - */ - public Mono removeMemberFromOrganization(String orgId, String userId) { - return getAdminAccessToken().flatMap(token -> this.webClient.delete() - .uri(getOrganizationMemberByIdEndpoint(orgId, userId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve() - .bodyToMono(Void.class)); - } - - /** - * Creates a new sub-group as a child of a parent group. - * - * @param parentGroupId The ID of the parent group. - * @param group A representation of the sub-group to create. - * @return A Mono that completes when the group is created. - * Keycloak returns location header, not body. - */ - public Mono createSubGroup(String parentGroupId, KeycloakGroupRepresentation group) { - return getAdminAccessToken().flatMap(token -> this.webClient.post() - .uri(getGroupChildrenEndpoint(parentGroupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(group) - .retrieve() - .bodyToMono(Void.class)); - } - - /** - * Retrieves a group by its unique ID. - * - * @param groupId The ID of the group to retrieve. - * @return A Mono emitting the group representation. - */ - public Mono getGroupById(String groupId) { - return getAdminAccessToken().flatMap(token -> this.webClient.get() - .uri(getGroupByIdEndpoint(groupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToMono(KeycloakGroupRepresentation.class)); - } - - /** - * Lists the direct children (sub-groups) of a parent group. - * - * @param parentGroupId The ID of the parent group. - * @return A Mono emitting a list of sub-group representations. - */ - public Mono> listSubGroups(String parentGroupId) { - return getAdminAccessToken().flatMap(token -> this.webClient.get() - .uri(getGroupChildrenEndpoint(parentGroupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToFlux(KeycloakGroupRepresentation.class) - .collectList()); - } - - /** - * Updates an existing group. - * - * @param groupId The ID of the group to update. - * @param group A representation containing the updated fields. - * @return A Mono that completes on successful update. - */ - public Mono updateGroup(String groupId, KeycloakGroupRepresentation group) { - return getAdminAccessToken().flatMap(token -> this.webClient.put() - .uri(getGroupByIdEndpoint(groupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(group) - .retrieve() - .bodyToMono(Void.class)); - } - - /** - * Deletes a group by its ID. - * - * @param groupId The ID of the group to delete. - * @return A Mono that completes on successful deletion. - */ - public Mono deleteGroup(String groupId) { - return getAdminAccessToken().flatMap(token -> this.webClient.delete() - .uri(getGroupByIdEndpoint(groupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToMono(Void.class)); - } - - /** - * Adds a user to a group. - * - * @param groupId The ID of the group. - * @param userId The ID of the user. - * @return A Mono that completes when the user is added to the group. - */ - public Mono addMemberToGroup(String groupId, String userId) { - // Keycloak API for adding a user to a group is a PUT on the user's groups link - return getAdminAccessToken().flatMap(token -> this.webClient.put() - .uri(getGroupMemberByIdEndpoint(groupId, userId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToMono(Void.class)); - } - - /** - * Retrieves all members of a specific group. - * - * @param groupId The ID of the group. - * @return A Mono emitting a list of user representations. - */ - public Mono> getGroupMembers(String groupId) { - return getAdminAccessToken().flatMap(token -> this.webClient.get() - .uri(getGroupMembersEndpoint(groupId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToFlux(KeycloakUserRepresentation.class) - .collectList()); - } - - /** - * Removes a user from a group. - * - * @param groupId The ID of the group. - * @param userId The ID of the user. - * @return A Mono that completes when the user is removed from the group. - */ - public Mono removeMemberFromGroup(String groupId, String userId) { - return getAdminAccessToken().flatMap(token -> this.webClient.delete() - .uri(getGroupMemberByIdEndpoint(groupId, userId)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .retrieve() - .bodyToMono(Void.class)); - } - -} \ No newline at end of file diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java new file mode 100644 index 0000000..c2e9bc8 --- /dev/null +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClient.java @@ -0,0 +1,473 @@ +package com.cleverthis.authservice.service.impl; + +import com.cleverthis.authservice.config.KeycloakClientConfigProperties; +import com.cleverthis.authservice.dto.RegistrationRequest; +import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; +import com.cleverthis.authservice.exception.RegistrationException; +import com.cleverthis.authservice.exception.ServiceUnavailableException; +import com.cleverthis.authservice.exception.UserAlreadyExistsException; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; + +/** + * Service to interact with Keycloak Admin API. + * Turn keycloak sdk into reactive. + */ +@Service +@Slf4j +public class KeycloakAdminReactiveClient { + + private final Keycloak keycloak; + + private final String realm; + + /** + * Create a keycloak admin client. + */ + public KeycloakAdminReactiveClient( + final Keycloak keycloak, + final KeycloakClientConfigProperties properties + ) { + this.keycloak = keycloak; + this.realm = properties.getRealm(); + } + + + /** + * Creates a new organization in Keycloak. + * + * @param orgToCreate A representation of the organization to be created. + * @return A Mono emitting the representation of the newly created organization, including its + * ID. + */ + public Mono createOrganization( + OrganizationRepresentation orgToCreate + ) { + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().create(orgToCreate); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.SUCCESSFUL) { + final var body = resp.readEntity(OrganizationRepresentation.class); + sink.success(body); + } else { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to create organization " + orgToCreate + + ", body=" + body)); + } + } catch (final Throwable t) { + sink.error(t); + } + }); + } + + /** + * Retrieves all organizations within the realm. + * + * @return A Mono emitting a List of all organization representations. + */ + public Mono> getOrganizations() { + return Mono.just(this.keycloak.realm(this.realm) + .organizations().list(null, Integer.MAX_VALUE)); + } + + /** + * Retrieves a single organization by its unique ID. + * + * @param orgId The ID of the organization to retrieve. + * @return A Mono emitting the representation of the found organization. + */ + public Mono getOrganizationById(String orgId) { + return Mono.create(sink -> { + try { + sink.success(this.keycloak.realm(this.realm) + .organizations().get(orgId).toRepresentation()); + } catch (final NotFoundException ex) { + sink.error(new NoSuchElementException( + "Organization with id " + orgId + " not exists", ex)); + } + }); + } + + /** + * Updates an existing organization's details in Keycloak. + * + * @param orgId The ID of the organization to update. + * @param orgToUpdate A representation containing the updated fields for the organization. + * @return A Mono that completes when the update is successful. + */ + public Mono updateOrganization( + String orgId, OrganizationRepresentation orgToUpdate + ) { + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().get(orgId).update(orgToUpdate); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to update organization " + orgId + + " with data " + orgToUpdate + + ", body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Deletes an organization from Keycloak. + * + * @param orgId The ID of the organization to delete. + * @return A Mono that completes when the deletion is successful. + */ + public Mono deleteOrganization(String orgId) { + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().get(orgId).delete(); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to delete organization " + orgId + + ", body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Sends an invitation for a user to join an organization. Keycloak handles the email delivery. + * + * @param orgId The ID of the organization to invite the user to. + * @param invitation An object containing the invitee's details (email, name). + * @return A Mono that completes when the invitation has been successfully sent. + */ + public Mono inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) { + // Create form data from the invitation DTO + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().get(orgId) + .members().inviteUser( + invitation.getEmail(), invitation.getFirstName(), invitation.getLastName() + ); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to process invitation request " + invitation + + " for organization " + orgId + + ", body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Retrieves a list of all members of a specific organization. + * + * @param orgId The ID of the organization. + * @return A Mono emitting a List of user representations for the members. + */ + public Mono> getOrganizationMembers(String orgId) { + return Mono.create(sink -> { + try { + sink.success(this.keycloak.realm(this.realm) + .organizations().get(orgId) + .members().list(null, Integer.MAX_VALUE)); + } catch (final NotFoundException ex) { + sink.error(new NoSuchElementException( + "Organization with id " + orgId + " not exists", ex)); + } + }); + } + + /** + * Adds an existing Keycloak user to an organization. + * + * @param orgId The ID of the organization. + * @param userId The ID of the user to add as a member. + * @return A Mono that completes when the user is successfully added. + */ + public Mono addMemberToOrganization(String orgId, String userId) { + // Keycloak's API to add an existing user to an Organization is a POST + // to the /members collection endpoint, with the user's ID in the body. + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().get(orgId) + .members().addMember(userId); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to add member " + userId + " to organization " + orgId + + ", body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Removes a member from an organization. + * + * @param orgId The ID of the organization. + * @param userId The ID of the user to remove. + * @return A Mono that completes when the user is successfully removed. + */ + public Mono removeMemberFromOrganization(String orgId, String userId) { + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .organizations().get(orgId) + .members().removeMember(userId); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to remove member " + userId + " from organization " + orgId + + ", body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Creates a new sub-group as a child of a parent group. + * + * @param parentGroupId The ID of the parent group. + * @param group A representation of the sub-group to create. + * @return A Mono that completes when the group is created. + * Keycloak returns location header, not body. + */ + public Mono createSubGroup(String parentGroupId, GroupRepresentation group) { + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm) + .groups().group(parentGroupId) + .subGroup(group); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.CLIENT_ERROR + || resp.getStatusInfo().getFamily() == Response.Status.Family.SERVER_ERROR) { + final var body = resp.readEntity(String.class); + sink.error(new ServiceUnavailableException( + "Failed to create sub-group '" + group.getName() + + "', body=" + body)); + } else { + sink.success(); + } + } + }); + } + + /** + * Retrieves a group by its unique ID. + * + * @param groupId The ID of the group to retrieve. + * @return A Mono emitting the group representation. + */ + public Mono getGroupById(String groupId) { + return Mono.create((Consumer>) sink -> + sink.success(this.keycloak.realm(this.realm) + .groups().group(groupId).toRepresentation())) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists", ex)); + } + + /** + * Lists the direct children (sub-groups) of a parent group. + * + * @param parentGroupId The ID of the parent group. + * @return A Mono emitting a list of sub-group representations. + */ + public Mono> listSubGroups(String parentGroupId) { + return Mono.create((Consumer>>) sink -> + sink.success(this.keycloak.realm(this.realm) + .groups().group(parentGroupId) + .getSubGroups(null, Integer.MAX_VALUE, false))) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException( + "Group with id " + parentGroupId + " not exists", ex)); + } + + /** + * Updates an existing group. + * + * @param groupId The ID of the group to update. + * @param group A representation containing the updated fields. + * @return A Mono that completes on successful update. + */ + public Mono updateGroup(String groupId, GroupRepresentation group) { + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).groups().group(groupId).update(group); + sink.success(); + }) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists" + )); + } + + /** + * Deletes a group by its ID. + * + * @param groupId The ID of the group to delete. + * @return A Mono that completes on successful deletion. + */ + public Mono deleteGroup(String groupId) { + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).groups().group(groupId).remove(); + sink.success(); + }) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exists" + )); + } + + /** + * Adds a user to a group. + * + * @param groupId The ID of the group. + * @param userId The ID of the user. + * @return A Mono that completes when the user is added to the group. + */ + public Mono addMemberToGroup(String groupId, String userId) { + // Keycloak API for adding a user to a group is a PUT on the user's groups link + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).users().get(userId).joinGroup(groupId); + sink.success(); + }) + .onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException( + "Failed to add user with id " + userId + " to group with id " + groupId + )); + } + + /** + * Retrieves all members of a specific group. + * + * @param groupId The ID of the group. + * @return A Mono emitting a list of user representations. + */ + public Mono> getGroupMembers(String groupId) { + return Mono.create((Consumer>>) sink -> sink.success( + this.keycloak.realm(this.realm).groups().group(groupId).members())) + .onErrorMap(NotFoundException.class, ex -> new NoSuchElementException( + "Group with id " + groupId + " not exist", ex + )); + } + + /** + * Removes a user from a group. + * + * @param groupId The ID of the group. + * @param userId The ID of the user. + * @return A Mono that completes when the user is removed from the group. + */ + public Mono removeMemberFromGroup(String groupId, String userId) { + return Mono + .create((Consumer>) sink -> { + this.keycloak.realm(this.realm).users().get(userId).leaveGroup(groupId); + sink.success(); + }) + .onErrorMap(ClientErrorException.class, ex -> new IllegalArgumentException( + "Failed to remove user with id " + userId + " from group with id " + groupId + )); + } + + /** + * Register user. + */ + public Mono registerUser(RegistrationRequest registrationRequest) { + log.info("Registering new user with email: {}", registrationRequest.getEmail()); + final var user = new UserRepresentation(); + user.setUsername(registrationRequest.getEmail()); + user.setEmail(registrationRequest.getEmail()); + user.setFirstName(registrationRequest.getFirstName()); + user.setLastName(registrationRequest.getLastName()); + user.setEnabled(true); + user.setEmailVerified(false); + + final var credentials = new CredentialRepresentation(); + credentials.setType(CredentialRepresentation.PASSWORD); + credentials.setValue(registrationRequest.getPassword()); + credentials.setTemporary(false); + user.setCredentials(Collections.singletonList(credentials)); + + // Tell Keycloak to initiate email verification, + // we may implement our email verification on future. + user.setRequiredActions(Collections.singletonList("VERIFY_EMAIL")); + + return Mono.create(sink -> { + final var resp = this.keycloak.realm(this.realm).users().create(user); + try (resp) { + if (resp.getStatusInfo().getFamily() == Response.Status.Family.SUCCESSFUL) { + log.info( + "User {} created successfully in Keycloak", + registrationRequest.getEmail()); + sink.success(); + } else if (resp.getStatus() == 409) { // CONFLICT + log.warn( + "User registration failed for {}:" + + " User already exists (Conflict).", + registrationRequest.getEmail()); + sink.error(new UserAlreadyExistsException("User with email " + + registrationRequest.getEmail() + + " already exists.")); + } else { + final var errorBody = resp.readEntity(String.class); + log.error("Keycloak user creation failed for {}" + + " with status {}: {}", + registrationRequest.getEmail(), resp.getStatus(), + errorBody); + sink.error(new RegistrationException( + "User registration failed due to Keycloak error." + + " Status: " + resp.getStatus())); + } + } + }); + + } + + /** + * Get user details with id. + * + * @throws NoSuchElementException in mono if keycloak returns 404. + */ + public Mono getUserDetails(String userId) { + return Mono.create((Consumer>) sink -> sink.success( + this.keycloak.realm(this.realm).users().get(userId).toRepresentation())) + .onErrorMap(NotFoundException.class, + ex -> new NoSuchElementException("User with id " + userId + " not exists", ex)); + } + +} \ No newline at end of file diff --git a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java index 5bf8caa..96838b1 100644 --- a/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java +++ b/src/main/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerService.java @@ -7,11 +7,7 @@ import com.cleverthis.authservice.dto.VerificationResponse; import com.cleverthis.authservice.dto.group.CreateGroupRequest; import com.cleverthis.authservice.dto.group.GroupResponse; import com.cleverthis.authservice.dto.group.UpdateGroupRequest; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation; import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation; -import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation; -import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation; import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest; import com.cleverthis.authservice.dto.organization.InviteUserRequest; import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse; @@ -20,20 +16,19 @@ import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest; import com.cleverthis.authservice.exception.AuthenticationException; import com.cleverthis.authservice.exception.LogoutException; import com.cleverthis.authservice.exception.PermissionCheckException; -import com.cleverthis.authservice.exception.RegistrationException; import com.cleverthis.authservice.exception.ServiceUnavailableException; import com.cleverthis.authservice.exception.TokenVerificationException; -import com.cleverthis.authservice.exception.UserAlreadyExistsException; import com.cleverthis.authservice.service.IdentityManagerService; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -42,7 +37,6 @@ import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; @@ -55,7 +49,7 @@ import reactor.core.publisher.Mono; public class KeycloakIdentityManagerService implements IdentityManagerService { private final WebClient webClient; - private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient + private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient private final String keycloakServerUrl; private final String realm; @@ -83,20 +77,13 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { .encode().toUriString(); } - private String getUsersAdminEndpoint() { - return UriComponentsBuilder.fromUriString(this.keycloakServerUrl) - .path("/admin/realms/{realm}/users") - .buildAndExpand(this.realm) - .encode().toUriString(); - } - /** * Create a keycloak OIDC client. */ @Autowired public KeycloakIdentityManagerService( - final WebClient keycloakWebClient, - final KeycloakAdminClient keycloakAdminClient, + @Qualifier("keycloakWebClient") final WebClient keycloakWebClient, + final KeycloakAdminReactiveClient keycloakAdminClient, final KeycloakClientConfigProperties configProperties ) { this.webClient = keycloakWebClient; @@ -119,23 +106,23 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { formData.add("scope", "openid email profile"); // Request standard scopes return this.webClient.post().uri(getTokenEndpoint()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)).retrieve() - // Handle specific HTTP status codes - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class) - .flatMap(errorBody -> { - log.error("Keycloak login failed with status {}: {}", - clientResponse.statusCode(), errorBody); - return Mono.error(new AuthenticationException( - "Login failed: Invalid credentials or Keycloak error." - + " Status: " - + clientResponse.statusCode())); - })) - .bodyToMono(TokenResponse.class) - .doOnSuccess(response -> log.debug("Login successful for user: {}", username)) - .doOnError(error -> log.error("Error during login for user {}: {}", username, - error.getMessage())); + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)).retrieve() + // Handle specific HTTP status codes + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error("Keycloak login failed with status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new AuthenticationException( + "Login failed: Invalid credentials or Keycloak error." + + " Status: " + + clientResponse.statusCode())); + })) + .bodyToMono(TokenResponse.class) + .doOnSuccess(response -> log.debug("Login successful for user: {}", username)) + .doOnError(error -> log.error("Error during login for user {}: {}", username, + error.getMessage())); } @Override @@ -147,29 +134,29 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { formData.add("token", accessToken); return this.webClient.post().uri(getIntrospectionEndpoint()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)).retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class) - .flatMap(errorBody -> { - log.error( - "Keycloak token introspection failed with" - + " status {}: {}", - clientResponse.statusCode(), errorBody); - return Mono.error(new TokenVerificationException( - "Token introspection failed. Status: " - + clientResponse.statusCode())); - })) - .bodyToMono(VerificationResponse.class).flatMap(response -> { - if (!response.isActive()) { - log.warn("Token verification failed: Token is inactive."); - return Mono.error( - new TokenVerificationException("Token is invalid or expired.")); - } - log.debug("Token verification successful. User: {}", response.getUsername()); - return Mono.just(response); - }).doOnError(error -> log.error("Error during token verification: {}", - error.getMessage())); + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)).retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + log.error( + "Keycloak token introspection failed with" + + " status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new TokenVerificationException( + "Token introspection failed. Status: " + + clientResponse.statusCode())); + })) + .bodyToMono(VerificationResponse.class).flatMap(response -> { + if (!response.isActive()) { + log.warn("Token verification failed: Token is inactive."); + return Mono.error( + new TokenVerificationException("Token is invalid or expired.")); + } + log.debug("Token verification successful. User: {}", response.getUsername()); + return Mono.just(response); + }).doOnError(error -> log.error("Error during token verification: {}", + error.getMessage())); } @Override @@ -182,104 +169,33 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak return this.webClient.post().uri(getRevocationEndpoint()) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData(formData)).retrieve() - .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), - clientResponse -> clientResponse.bodyToMono(String.class) - .flatMap(errorBody -> { - // Keycloak might return 400 if token is already invalid, which - // is okay for logout - if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) { - log.warn( - "Token revocation returned 400 (possibly already" - + " invalid/revoked): {}", - errorBody); - return Mono.empty(); // Treat as success in logout scenario - } - log.error("Keycloak token revocation failed with status {}: {}", - clientResponse.statusCode(), errorBody); - return Mono.error(new LogoutException("Logout failed. Status: " - + clientResponse.statusCode())); - })) - .bodyToMono(Void.class) // Expecting empty body on success (2xx) - .doOnSuccess(v -> log.debug("Logout successful (token revoked).")) - .doOnError(error -> log.error("Error during logout: {}", error.getMessage())); + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)).retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(errorBody -> { + // Keycloak might return 400 if token is already invalid, which + // is okay for logout + if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) { + log.warn( + "Token revocation returned 400 (possibly already" + + " invalid/revoked): {}", + errorBody); + return Mono.empty(); // Treat as success in logout scenario + } + log.error("Keycloak token revocation failed with status {}: {}", + clientResponse.statusCode(), errorBody); + return Mono.error(new LogoutException("Logout failed. Status: " + + clientResponse.statusCode())); + })) + .bodyToMono(Void.class) // Expecting empty body on success (2xx) + .doOnSuccess(v -> log.debug("Logout successful (token revoked).")) + .doOnError(error -> log.error("Error during logout: {}", error.getMessage())); } @Override public Mono registerUser(RegistrationRequest registrationRequest) { - log.info("Registering new user with email: {}", registrationRequest.getEmail()); - - Map userRepresentation = new HashMap<>(); - userRepresentation.put("username", registrationRequest.getEmail()); - userRepresentation.put("email", registrationRequest.getEmail()); - userRepresentation.put("firstName", registrationRequest.getFirstName()); - userRepresentation.put("lastName", registrationRequest.getLastName()); - userRepresentation.put("enabled", true); - userRepresentation.put("emailVerified", false); // Initially false - - Map credential = new HashMap<>(); - credential.put("type", "password"); - credential.put("value", registrationRequest.getPassword()); - credential.put("temporary", false); - userRepresentation.put("credentials", Collections.singletonList(credential)); - - // Tell Keycloak to initiate email verification, - // we may implement our email verification on future. - userRepresentation.put("requiredActions", Collections.singletonList("VERIFY_EMAIL")); - - return this.keycloakAdminClient.getAdminAccessToken() - .flatMap(adminToken -> this.webClient.post().uri(getUsersAdminEndpoint()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) - .contentType(MediaType.APPLICATION_JSON).bodyValue(userRepresentation) - .exchangeToMono(response -> { - if (response.statusCode().is2xxSuccessful()) { - log.info( - "User {} created successfully in Keycloak." - + " Keycloak will handle email verification.", - registrationRequest.getEmail()); - return Mono.empty(); // Successfully initiated - } else if (response.statusCode() == HttpStatus.CONFLICT) { - log.warn( - "User registration failed for {}:" - + " User already exists (Conflict).", - registrationRequest.getEmail()); - return response.bodyToMono(String.class) - .flatMap(errorBody -> Mono.error( - new UserAlreadyExistsException("User with email " - + registrationRequest.getEmail() - + " already exists."))); - } else { - return response.bodyToMono(String.class).flatMap(errorBody -> { - log.error("Keycloak user creation failed for {}" - + " with status {}: {}", - registrationRequest.getEmail(), response.statusCode(), - errorBody); - return Mono.error(new RegistrationException( - "User registration failed due to Keycloak error." - + " Status: " + response.statusCode())); - }); - } - })) - .doOnError(WebClientResponseException.class, e -> { - if (e.getStatusCode() == HttpStatus.CONFLICT) { - // Already handled by exchangeToMono, but good to log if it somehow gets - // here - log.warn( - "User registration conflict " - + "(WebClientResponseException) for {}: {}", - registrationRequest.getEmail(), e.getMessage()); - } else { - log.error("WebClientResponseException during user registration for {}: {}", - registrationRequest.getEmail(), e.getMessage()); - } - }) - .doOnError( - e -> !(e instanceof UserAlreadyExistsException - || e instanceof RegistrationException), - e -> log.error("Generic error during user registration for {}: {}", - registrationRequest.getEmail(), e.getMessage())) - .then(); + return this.keycloakAdminClient.registerUser(registrationRequest); } @Override @@ -327,11 +243,11 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { error.getMessage())) .onErrorResume(PermissionCheckException.class, e -> Mono.just(false)); } - - + + @Override public Mono createOrganization(CreateOrganizationRequest request) { - KeycloakOrganizationRepresentation newOrg = new KeycloakOrganizationRepresentation(); + OrganizationRepresentation newOrg = new OrganizationRepresentation(); newOrg.setName(request.getName()); newOrg.setDescription(request.getDescription()); newOrg.setAttributes(request.getAttributes()); @@ -339,25 +255,29 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { if (request.getDomains() != null) { Set domainRepresentations = request.getDomains() - .stream() - .map(domainName -> new OrganizationDomainRepresentation(domainName, false)) - .collect(Collectors.toSet()); - newOrg.setDomains(domainRepresentations); + .stream() + .map(domainName -> { + final var domain = new OrganizationDomainRepresentation(domainName); + domain.setVerified(false); + return domain; + }) + .collect(Collectors.toSet()); + domainRepresentations.forEach(newOrg::addDomain); } return this.keycloakAdminClient.createOrganization(newOrg) - .map(this::toOrganizationResponse); + .map(this::toOrganizationResponse); } @Override public Mono> getOrganizations() { return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream() - .map(this::toOrganizationResponse).collect(Collectors.toList())); + .map(this::toOrganizationResponse).collect(Collectors.toList())); } @Override public Mono getOrganizationById(String orgId) { return this.keycloakAdminClient.getOrganizationById(orgId) - .map(this::toOrganizationResponse); + .map(this::toOrganizationResponse); } @Override @@ -370,10 +290,17 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { // Update domains based on the new list of names if (request.getDomains() != null) { Set domainRepresentations = request.getDomains() - .stream() - .map(domainName -> new OrganizationDomainRepresentation(domainName, false)) - .collect(Collectors.toSet()); - existingOrg.setDomains(domainRepresentations); + .stream() + .map(domainName -> { + final var domain = new OrganizationDomainRepresentation(domainName); + domain.setVerified(false); + return domain; + }) + .collect(Collectors.toSet()); + if (existingOrg.getDomains() != null) { + existingOrg.getDomains().clear(); + } + domainRepresentations.forEach(existingOrg::addDomain); } return this.keycloakAdminClient.updateOrganization(orgId, existingOrg); }); @@ -397,7 +324,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { @Override public Mono> getOrganizationMembers(String orgId) { return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList - .stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList())); + .stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList())); } @Override @@ -411,15 +338,16 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { } @Override - public Mono createSubGroup(final String parentGroupId, - final CreateGroupRequest request) { - KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation(); + public Mono createSubGroup( + final String parentGroupId, final CreateGroupRequest request + ) { + GroupRepresentation newGroup = new GroupRepresentation(); newGroup.setName(request.getName()); newGroup.setAttributes(request.getAttributes()); - + return this.keycloakAdminClient.createSubGroup(parentGroupId, newGroup) - .thenReturn(new GroupResponse(null, request.getName(), - null, null, request.getAttributes())); + .thenReturn(new GroupResponse(null, request.getName(), + null, null, request.getAttributes())); } @Override @@ -430,14 +358,14 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { @Override public Mono> listSubGroups(final String parentGroupId) { return this.keycloakAdminClient.listSubGroups(parentGroupId) - .map(groupList -> groupList.stream() - .map(this::toGroupResponse) - .collect(Collectors.toList())); + .map(groupList -> groupList.stream() + .map(this::toGroupResponse) + .collect(Collectors.toList())); } @Override public Mono updateGroup(final String groupId, final UpdateGroupRequest request) { - KeycloakGroupRepresentation groupUpdate = new KeycloakGroupRepresentation(); + final var groupUpdate = new GroupRepresentation(); groupUpdate.setAttributes(request.getAttributes()); return this.keycloakAdminClient.updateGroup(groupId, groupUpdate); } @@ -446,18 +374,18 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { public Mono deleteGroup(final String groupId) { return this.keycloakAdminClient.deleteGroup(groupId); } - + @Override public Mono addMemberToGroup(final String groupId, final String userId) { return this.keycloakAdminClient.addMemberToGroup(groupId, userId); } - + @Override public Mono> getGroupMembers(final String groupId) { return this.keycloakAdminClient.getGroupMembers(groupId) - .map(userList -> userList.stream() - .map(this::toOrganizationMemberResponse) - .collect(Collectors.toList())); + .map(userList -> userList.stream() + .map(this::toOrganizationMemberResponse) + .collect(Collectors.toList())); } @Override @@ -466,39 +394,39 @@ public class KeycloakIdentityManagerService implements IdentityManagerService { } // --- Add DTO Mapper for Group --- - private GroupResponse toGroupResponse(final KeycloakGroupRepresentation keycloakGroup) { + private GroupResponse toGroupResponse(final GroupRepresentation keycloakGroup) { if (keycloakGroup == null) { return null; } return new GroupResponse( - keycloakGroup.getId(), - keycloakGroup.getName(), - keycloakGroup.getPath(), - keycloakGroup.getDescription(), - keycloakGroup.getAttributes() + keycloakGroup.getId(), + keycloakGroup.getName(), + keycloakGroup.getPath(), + keycloakGroup.getDescription(), + keycloakGroup.getAttributes() ); } - + // --- DTO Mapper Helper Methods --- private OrganizationResponse toOrganizationResponse( - KeycloakOrganizationRepresentation keycloakOrg) { + OrganizationRepresentation keycloakOrg) { if (keycloakOrg == null) { return null; } return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(), - keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.getEnabled(), - keycloakOrg.getAttributes()); + keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.isEnabled(), + keycloakOrg.getAttributes()); } private OrganizationMemberResponse toOrganizationMemberResponse( - KeycloakUserRepresentation keycloakUser) { + UserRepresentation keycloakUser) { if (keycloakUser == null) { return null; } return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(), - keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(), - keycloakUser.isEnabled()); + keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(), + keycloakUser.isEnabled()); } - + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 98d06bd..7f7b362 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: name: auth-service cloud: consul: - host: ${CONSUL_HOST:localhost} + host: ${CONSUL_HOST:127.0.0.1} port: ${CONSUL_PORT:8500} config: import-check: @@ -15,8 +15,8 @@ spring: # Keycloak Configuration (adjust values as needed) keycloak: - keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash) - keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://localhost:9100} # Base URL of your Keycloak instance (NO trailing slash) + keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://keycloak.dev.localhost} # Base URL of your Keycloak instance (NO trailing slash) + keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://keycloak.dev.localhost} # Base URL of your Keycloak instance (NO trailing slash) realm: ${KEYCLOAK_AUTH_REALM:clevermicro-dev} # The realm name you are using client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!) @@ -34,9 +34,46 @@ auth-service: # Default Keycloak Client ID if no prefix matches (optional) # defaultKeycloakClientId: "general-api-client" +# CleverMicro client +clevermicro: + client: + # rabbitmq + rabbitmq-host: ${RABBITMQ_HOST:localhost} + rabbitmq-port: ${RABBITMQ_PORT:5672} + rabbitmq-username: ${RABBITMQ_USER:springuser} + rabbitmq-password: ${RABBITMQ_PASSWORD:TheCleverWho} + rabbitmq-vhost: ${RABBITMQ_VHOST:/} + # swarm env + swarm-service-id: ${SWARM_SERVICE_ID:auth-service-id} + swarm-task-id=: ${SWARM_TASK_ID:dummy-task-id} + # backpressure + initial-availability: ${MAX_AVAILABILITY:-1} + logging: level: # Set log levels as needed. com.clevermicro.authservice: DEBUG org.springframework.web.client.RestTemplate: DEBUG - reactor.netty.http.client: DEBUG \ No newline at end of file + reactor.netty.http.client: DEBUG + +# Enable prometheus endpoint +management: + endpoint: + prometheus: + access: read_only + web: + exposure: + include: health,prometheus +# OpenTelemetry +otel: + logs: + exporter: otlp + exporter: + otlp: + logs: + endpoint: ${OTLP_LOG_ENDPOINT:http://victorialogs.dev.localhost/insert/opentelemetry/v1/logs} + # for now disable the tracing and metrics exporter + traces: + exporter: none + metrics: + exporter: none \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/OrganizationControllerTest.java b/src/test/java/com/cleverthis/authservice/OrganizationControllerTest.java index 32129a1..c522d30 100644 --- a/src/test/java/com/cleverthis/authservice/OrganizationControllerTest.java +++ b/src/test/java/com/cleverthis/authservice/OrganizationControllerTest.java @@ -5,7 +5,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import com.cleverthis.authservice.controller.OrganizationController; -import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation; import com.cleverthis.authservice.dto.organization.AddMemberRequest; import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest; import com.cleverthis.authservice.dto.organization.InviteUserRequest; @@ -21,6 +20,7 @@ import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; import org.springframework.context.annotation.Import; @@ -34,7 +34,7 @@ import reactor.core.publisher.Mono; * management, mocking the service layer. */ @WebFluxTest(OrganizationController.class) -@Import(GlobalExceptionHandler.class) +@Import(GlobalExceptionHandler.class) public class OrganizationControllerTest { @Autowired @@ -52,10 +52,10 @@ public class OrganizationControllerTest { @BeforeEach void setUp() { // Create the correct domain representation object - OrganizationDomainRepresentation domain = - new OrganizationDomainRepresentation("test-org.com", true); + final var domain = new OrganizationDomainRepresentation("test-org.com"); + domain.setVerified(true); this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain), - "A test organization", true, Map.of("displayName", List.of("Test Org"))); + "A test organization", true, Map.of("displayName", List.of("Test Org"))); this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse); } @@ -65,14 +65,14 @@ public class OrganizationControllerTest { @Test void createOrganization_Success_ReturnsCreatedWithBody() { CreateOrganizationRequest request = new CreateOrganizationRequest("test-org", - List.of("test-org.com"), "A test org", null); + List.of("test-org.com"), "A test org", null); when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class))) - .thenReturn(Mono.just(this.mockOrgResponse)); + .thenReturn(Mono.just(this.mockOrgResponse)); this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON) - .bodyValue(request).exchange().expectStatus().isCreated() - .expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse); + .bodyValue(request).exchange().expectStatus().isCreated() + .expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse); } /** @@ -83,7 +83,7 @@ public class OrganizationControllerTest { CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null); this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON) - .bodyValue(request).exchange().expectStatus().isBadRequest(); + .bodyValue(request).exchange().expectStatus().isBadRequest(); } /** @@ -92,10 +92,10 @@ public class OrganizationControllerTest { @Test void getOrganizations_Success_ReturnsListOfOrgs() { when(this.identityManagerService.getOrganizations()) - .thenReturn(Mono.just(this.mockOrgResponseList)); + .thenReturn(Mono.just(this.mockOrgResponseList)); this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk() - .expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList); + .expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList); } /** @@ -104,11 +104,11 @@ public class OrganizationControllerTest { @Test void getOrganizationById_Exists_ReturnsOrg() { when(this.identityManagerService.getOrganizationById("org-id-123")) - .thenReturn(Mono.just(this.mockOrgResponse)); + .thenReturn(Mono.just(this.mockOrgResponse)); this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange() - .expectStatus().isOk().expectBody(OrganizationResponse.class) - .isEqualTo(this.mockOrgResponse); + .expectStatus().isOk().expectBody(OrganizationResponse.class) + .isEqualTo(this.mockOrgResponse); } /** @@ -117,10 +117,10 @@ public class OrganizationControllerTest { @Test void getOrganizationById_NotFound_ReturnsNotFound() { when(this.identityManagerService.getOrganizationById("non-existent-id")) - .thenReturn(Mono.error(new ResourceNotFoundException("Organization not found"))); + .thenReturn(Mono.error(new ResourceNotFoundException("Organization not found"))); this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange() - .expectStatus().isNotFound(); + .expectStatus().isNotFound(); } /** @@ -129,13 +129,13 @@ public class OrganizationControllerTest { @Test void updateOrganization_Success_ReturnsNoContent() { UpdateOrganizationRequest request = - new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null); + new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null); when(this.identityManagerService.updateOrganization(eq("org-id-123"), - any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty()); + any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty()); this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123") - .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() - .expectStatus().isNoContent(); + .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() + .expectStatus().isNoContent(); } /** @@ -146,7 +146,7 @@ public class OrganizationControllerTest { when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty()); this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange() - .expectStatus().isNoContent(); + .expectStatus().isNoContent(); } /** @@ -156,11 +156,11 @@ public class OrganizationControllerTest { void inviteUserToOrganization_Success_ReturnsOk() { InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User"); when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"), - any(InviteUserRequest.class))).thenReturn(Mono.empty()); + any(InviteUserRequest.class))).thenReturn(Mono.empty()); this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123") - .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() - .expectStatus().isOk(); + .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() + .expectStatus().isOk(); } /** @@ -169,14 +169,14 @@ public class OrganizationControllerTest { @Test void getOrganizationMembers_Success_ReturnsListOfMembers() { List members = - List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User", - "test@test.com", true)); + List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User", + "test@test.com", true)); when(this.identityManagerService.getOrganizationMembers("org-id-123")) - .thenReturn(Mono.just(members)); + .thenReturn(Mono.just(members)); this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange() - .expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class) - .isEqualTo(members); + .expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class) + .isEqualTo(members); } /** @@ -186,11 +186,11 @@ public class OrganizationControllerTest { void addMemberToOrganization_Success_ReturnsNoContent() { AddMemberRequest request = new AddMemberRequest("user-to-add-id"); when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id")) - .thenReturn(Mono.empty()); + .thenReturn(Mono.empty()); this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123") - .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() - .expectStatus().isNoContent(); + .contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange() + .expectStatus().isNoContent(); } /** @@ -199,10 +199,10 @@ public class OrganizationControllerTest { @Test void removeMemberFromOrganization_Success_ReturnsNoContent() { when(this.identityManagerService.removeMemberFromOrganization("org-id-123", - "user-to-remove-id")).thenReturn(Mono.empty()); + "user-to-remove-id")).thenReturn(Mono.empty()); this.webTestClient.delete() - .uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id") - .exchange().expectStatus().isNoContent(); + .uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id") + .exchange().expectStatus().isNoContent(); } } diff --git a/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java index f077fc6..9ab1896 100644 --- a/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java +++ b/src/test/java/com/cleverthis/authservice/config/KeycloakClientConfigPropertiesTest.java @@ -3,10 +3,17 @@ package com.cleverthis.authservice.config; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; -@SpringBootTest(properties = { +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {KeycloakClientConfigPropertiesTest.class}) +@EnableConfigurationProperties({KeycloakClientConfigProperties.class}) +@TestPropertySource(properties = { "keycloak.keycloak-host=https://keycloak.example.com", "keycloak.keycloak-admin-host=https://keycloak-admin.example.com", "keycloak.realm=cm-test", diff --git a/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java new file mode 100644 index 0000000..dbceb82 --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/AbstractEndpointTest.java @@ -0,0 +1,238 @@ +package com.cleverthis.authservice.controller.rabbitmq; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointErrorResponse; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointOkResponse; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerSubmodule; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.BuiltinExchangeType; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class AbstractEndpointTest { + private final CleverMicroAmqpClient client = Mockito.mock(CleverMicroAmqpClient.class); + final ObjectMapper objectMapper = Mockito.spy(new ObjectMapper()); + + private class TestEndpoint extends AbstractEndpoint { + + TestEndpoint() throws Exception { + super( + AbstractEndpointTest.this.client, + AbstractEndpointTest.this.objectMapper, + "test-key" + ); + } + + // The throws is needed for mocked object to throw exception during test + @SuppressWarnings("RedundantThrows") + @Override + protected void handleRequest( + final HandlerContext ctx, final EndpointRequest request + ) throws EndpointException { + } + } + + private final HandlerSubmodule handlerSubmodule = Mockito.mock(HandlerSubmodule.class); + + @BeforeEach + void setup() { + when(this.client.getHandlerManager()).thenReturn(this.handlerSubmodule); + } + + @Test + void testInitAndClose() throws Exception { + when(this.client.getHandlerManager().registerHandler( + anyString(), eq(""), eq(1), + notNull(), notNull() + )).thenReturn("test-tag"); + final var testEndpoint = new TestEndpoint(); + verify(this.client.getHandlerManager()).declareExchange( + "cleverthis.clevermicro.auth.endpoints", BuiltinExchangeType.DIRECT + ); + verify(this.client.getHandlerManager()).declareQueue( + "cleverthis.clevermicro.auth.endpoints.test-key", + true, false, false, Map.of() + ); + verify(this.client.getHandlerManager()).bindQueue( + "cleverthis.clevermicro.auth.endpoints.test-key", + "cleverthis.clevermicro.auth.endpoints", + "test-key", Map.of() + ); + + testEndpoint.close(); + verify(this.client.getHandlerManager()).cancelHandler("test-tag"); + } + + @Test + void testHandler() throws Throwable { + final var testEndpoint = Mockito.spy(new TestEndpoint()); + final var handlerContext = Mockito.mock(HandlerContext.class); + + // silent reply method, it will be tested separately + doNothing().when(testEndpoint).reply(eq(handlerContext), notNull()); + + // parse req null + doReturn(null).when(testEndpoint).parseRequest(notNull()); + testEndpoint.handleRabbitMessage(handlerContext); + verify(testEndpoint).reply(eq(handlerContext), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("cleverthis.clevermicro.bad_request") + && resp.getMessage().equals("Malformed request, cannot be parsed"); + })); + + // path: parse req ok + Mockito.clearInvocations(testEndpoint); + // handle req ok + doReturn(EndpointRequest.builder().build()).when(testEndpoint).parseRequest(notNull()); + doNothing().when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint, never()).reply(notNull(), notNull()); + + // handle req endpoint exception + Mockito.clearInvocations(testEndpoint); + doThrow(new EndpointException("test", "message")) + .when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint).reply(notNull(), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("test") + && resp.getMessage().equals("message"); + })); + + // handle req any exception + Mockito.clearInvocations(testEndpoint); + doThrow(new RuntimeException("test")) + .when(testEndpoint).handleRequest(notNull(), notNull()); + assertDoesNotThrow(() -> testEndpoint.handleRabbitMessage(handlerContext)); + verify(testEndpoint).reply(notNull(), argThat(it -> { + final var resp = (EndpointErrorResponse) it; + return resp.getException().equals("cleverthis.clevermicro.server_internal_error"); + })); + } + + @Test + void testParseRequest_SingleStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream)); + final var req = EndpointRequest.builder().method("testMethod").build(); + doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertEquals(req, result); + } + + @Test + void testParseRequest_MultiStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.of( + Map.of("request", inputStream) + )); + final var req = EndpointRequest.builder().method("testMethod").build(); + doReturn(req).when(this.objectMapper).readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertEquals(req, result); + } + + @Test + void testReply_SuccessfulSerialization() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = Map.of("key", "value"); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + verify(handlerContext).finish(""" + { + "key" : "value" + } + """.trim().getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testReply_SerializationFailure() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = mock(EndpointOkResponse.class); + // throw error when accessing fields, causing jackson failed to serialize + when(response.getResult()).thenThrow(new RuntimeException("test")); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + verify(handlerContext, never()).finish(any()); + verify(handlerContext).refillAvailability(); + } + + @Test + void testReply_ReplyFailure() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var response = Map.of("key", "value"); + + doThrow(new IOException("Reply error")).when(handlerContext).finish(notNull()); + + assertDoesNotThrow(() -> testEndpoint.reply(handlerContext, response)); + } + + @Test + void testParseRequest_InvalidRequest() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + final var inputStream = Mockito.mock(InputStream.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.of(inputStream)); + doThrow(new IOException("test")).when(this.objectMapper) + .readValue(inputStream, EndpointRequest.class); + + final var result = testEndpoint.parseRequest(handlerContext); + assertNull(result); + } + + @Test + void testParseRequest_NoStream() throws Exception { + //noinspection resource + final var testEndpoint = new TestEndpoint(); + final var handlerContext = Mockito.mock(HandlerContext.class); + + when(handlerContext.getMessageBodySingleStream()).thenReturn(Optional.empty()); + when(handlerContext.getMessageBodyMultiStream()).thenReturn(Optional.empty()); + + final var result = testEndpoint.parseRequest(handlerContext); + assertNull(result); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java new file mode 100644 index 0000000..a184301 --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/controller/rabbitmq/v1/user/UserEndpointV1Test.java @@ -0,0 +1,119 @@ +package com.cleverthis.authservice.controller.rabbitmq.v1.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointBadRequestException; +import com.cleverthis.authservice.controller.rabbitmq.exception.EndpointException; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointRequest; +import com.cleverthis.authservice.controller.rabbitmq.model.EndpointResponses; +import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient; +import com.cleverthis.clevermicro.client.amqp.CleverMicroAmqpClient; +import com.cleverthis.clevermicro.client.amqp.handler.HandlerContext; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.UserRepresentation; +import reactor.core.publisher.Mono; + +class UserEndpointV1Test { + private final CleverMicroAmqpClient client = mock(); + private final ObjectMapper objectMapper = spy(new ObjectMapper()); + private final KeycloakAdminReactiveClient keycloakAdminClient = mock(); + + private UserEndpointV1 userEndpointV1; + + @BeforeEach + void setup() throws IOException { + when(this.client.getHandlerManager()).thenReturn(mock()); + this.userEndpointV1 = spy(new UserEndpointV1( + this.client, this.objectMapper, this.keycloakAdminClient)); + } + + @Test + void testHandler_MethodNotFound() { + final var req = mock(EndpointRequest.class); + when(req.getMethod()).thenReturn("method that doesn't exist"); + + final var ex = assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(mock(), req)); + + assertEquals("Method not found", ex.getMessage()); + } + + private EndpointRequest createRequest(String method, Map args) { + final String json; + try { + json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(args); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + final LinkedHashMap convertedArgs; + try { + convertedArgs = this.objectMapper.readValue(json, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return EndpointRequest.builder().method(method).args(convertedArgs).build(); + } + + @Test + void testHandler_QueryUserById() throws Exception { + final var handlerContext = mock(HandlerContext.class); + + // normal path + var req = createRequest("queryUserById", Map.of("id", "test-id")); + final var returnedUser = new UserRepresentation(); + returnedUser.setId("test-id"); + returnedUser.setUsername("test-username"); + when(this.keycloakAdminClient.getUserDetails("test-id")) + .thenReturn(Mono.just(returnedUser)); + + this.userEndpointV1.handleRequest(handlerContext, req); + + verify(handlerContext).finish( + this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes( + EndpointResponses.ok(returnedUser) + ) + ); + + // missing args + req = createRequest("queryUserById", Map.of("not-id", "test-id")); + { // scope for final copy + final var finalReq = req; + assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + } + + // invalid args + req = createRequest("queryUserById", Map.of("id", 123)); + { // scope for final copy + final var finalReq = req; + assertThrows(EndpointBadRequestException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + } + + // user not found + when(this.keycloakAdminClient.getUserDetails("test-id")) + .thenReturn(Mono.error(new NoSuchElementException("test exception"))); + req = createRequest("queryUserById", Map.of("id", "test-id")); + { // scope for final copy + final var finalReq = req; + final var ex = assertThrows(EndpointException.class, + () -> this.userEndpointV1.handleRequest(handlerContext, finalReq)); + assertEquals("cleverthis.clevermicro.auth.user_not_found", ex.getEndpointException()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java b/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java index c11cd36..5074cbc 100644 --- a/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java +++ b/src/test/java/com/cleverthis/authservice/service/impl/FallbackPermissionMapLookupServiceTest.java @@ -55,7 +55,8 @@ class FallbackPermissionMapLookupServiceTest { assertNull(service.getConfig()); // fallback is null // has json content when(value.getDecodedValue()) - .thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties())); + .thenReturn( + this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties())); service.refreshConsul(); assertEquals(new PermissionMappingConfigProperties(), service.getConfig()); } @@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest { } @Test - void testMatchClientIdByPath() { + void testMatchRuleByPath() { final var defaultConfig = new PermissionMappingConfigProperties(); - defaultConfig.setRules(List.of( - PermissionMappingConfigProperties.Rule.builder() - .keycloakClientId("service-A").pathPrefix("/a/").build(), - PermissionMappingConfigProperties.Rule.builder() - .keycloakClientId("service-B").pathPrefix("/b/").build() - )); + final var ruleA = PermissionMappingConfigProperties.Rule.builder() + .keycloakClientId("service-A").pathPrefix("/a/").build(); + final var ruleB = PermissionMappingConfigProperties.Rule.builder() + .keycloakClientId("service-B").pathPrefix("/b/").build(); + defaultConfig.setRules(List.of(ruleA, ruleB)); final var service = new FallbackPermissionMapLookupService( defaultConfig, this.consulClient, this.objectMapper); assertEquals( - "service-B", - service.matchClientIdByPath("/b/items/123").orElseThrow()); + ruleB, + service.matchRuleByPath("/b/items/123").orElseThrow()); assertEquals( Optional.empty(), - service.matchClientIdByPath("/c/items/123")); + service.matchRuleByPath("/c/items/123")); } - @Test - void testMatchRuleByClientIdAndPath() { - final var defaultConfig = new PermissionMappingConfigProperties(); - defaultConfig.setRules(List.of( - PermissionMappingConfigProperties.Rule.builder() - .keycloakClientId("service-A").pathPrefix("/a/").build(), - PermissionMappingConfigProperties.Rule.builder() - .keycloakClientId("service-B").pathPrefix("/b/").build() - )); - final var service = new FallbackPermissionMapLookupService( - defaultConfig, this.consulClient, this.objectMapper); - - service.matchRuleByClientIdAndPath("service-A", "/a/items/123") - .ifPresentOrElse( - rule -> assertEquals("service-A", rule.getKeycloakClientId()), - () -> Assertions.fail("No rule matched")); - service.matchRuleByClientIdAndPath("service-B", "/a/items/123") - .ifPresentOrElse( - it -> Assertions.fail("Rule matched but should not have"), - () -> {} - ); - } } \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java deleted file mode 100644 index 232fbbe..0000000 --- a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminClientTest.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.cleverthis.authservice.service.impl; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.cleverthis.authservice.config.KeycloakClientConfigProperties; -import com.cleverthis.authservice.dto.KeycloakRoleRepresentation; -import com.cleverthis.authservice.exception.ServiceUnavailableException; -import java.io.IOException; -import java.util.List; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -class KeycloakAdminClientTest { - private MockWebServer mockBackEnd; - - private KeycloakAdminClient adminClient; - - @BeforeEach - void setup() throws IOException { - // isolate requests by creating one mock server per test - this.mockBackEnd = new MockWebServer(); - this.mockBackEnd.start(); - - final var webClient = WebClient.builder().build(); - final var configProperties = KeycloakClientConfigProperties.builder() - .keycloakAdminHost("http://localhost:" + this.mockBackEnd.getPort() + "/") - .realm("test-realm") - .adminAccountUsername("admin") - .adminAccountPassword("admin-password") - .build(); - - this.adminClient = Mockito.spy(new KeycloakAdminClient(webClient, configProperties)); - - } - - @AfterEach - void tearDown() throws IOException { - this.mockBackEnd.shutdown(); - } - - - @Test - void getAdminAccessToken_shouldReturnToken_whenRequestIsSuccessful() - throws InterruptedException { - // Arrange - final var tokenResponse = """ - { - "access_token": "mock-access-token", - "expires_in": 3600 - } - """; - - this.mockBackEnd.enqueue(new MockResponse() - .setBody(tokenResponse) - .addHeader("Content-Type", "application/json") - ); - - // Act - final var result = this.adminClient.getAdminAccessToken(); - - // Assert - StepVerifier.create(result) - .expectNext("mock-access-token") - .verifyComplete(); - - RecordedRequest recordedRequest = this.mockBackEnd.takeRequest(); - assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath()); - } - - @Test - void getClientInternalId_shouldReturnInternalId_whenClientExists() throws InterruptedException { - // Arrange - final var publicClientId = "test-client"; - final var internalClientId = "mock-internal-id"; - Mockito.when(this.adminClient.getAdminAccessToken()) - .thenReturn(Mono.just("mock-access-token")); - - final var responseBody = """ - [ - { - "id": "mock-internal-id", - "clientId": "test-client" - } - ] - """; - - this.mockBackEnd.enqueue(new MockResponse() - .setBody(responseBody) - .addHeader("Content-Type", "application/json") - ); - - // Act - final var result = this.adminClient.getClientInternalId(publicClientId); - - // Assert - StepVerifier.create(result) - .expectNext(internalClientId) - .verifyComplete(); - RecordedRequest recordedRequest = this.mockBackEnd.takeRequest(); - assertEquals("/admin/realms/test-realm/clients?clientId=test-client&max=1", - recordedRequest.getPath()); - } - - @Test - void getClientInternalId_shouldThrowError_whenClientDoesNotExist() { - // Arrange - final var publicClientId = "nonexistent-client"; - final var responseBody = "[]"; - Mockito.when(this.adminClient.getAdminAccessToken()) - .thenReturn(Mono.just("mock-access-token")); - - this.mockBackEnd.enqueue(new MockResponse() - .setBody(responseBody) - .addHeader("Content-Type", "application/json") - ); - - // Act - final var result = this.adminClient.getClientInternalId(publicClientId); - - // Assert - StepVerifier.create(result) - .expectErrorMatches(throwable -> throwable instanceof ServiceUnavailableException && - throwable.getMessage().contains("Client not found in Keycloak")) - .verify(); - } - - @Test - void getUserEffectiveClientRoles_shouldReturnRoles_whenRequestIsSuccessful() - throws InterruptedException { - // Arrange - final var userId = "test-user"; - final var clientInternalId = "mock-client-id"; - Mockito.when(this.adminClient.getAdminAccessToken()) - .thenReturn(Mono.just("mock-access-token")); - - final var rolesResponse = """ - [ - {"name": "role1"}, - {"name": "role2"} - ] - """; - - this.mockBackEnd.enqueue(new MockResponse() - .setBody(rolesResponse) - .addHeader("Content-Type", "application/json") - ); - - // Act - final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId); - - // Assert - StepVerifier.create(result) - .expectNextMatches(roles -> roles.stream().map(KeycloakRoleRepresentation::getName) - .toList().containsAll(List.of("role1", "role2"))) - .verifyComplete(); - RecordedRequest recordedRequest = this.mockBackEnd.takeRequest(); - assertEquals( - "/admin/realms/test-realm/users/test-realm/role-mappings/clients/test-user/composite", - recordedRequest.getPath()); - } - - @Test - void getUserEffectiveClientRoles_shouldReturnEmptyList_whenRolesNotFound() { - // Arrange - final var userId = "test-user"; - final var clientInternalId = "mock-client-id"; - Mockito.when(this.adminClient.getAdminAccessToken()) - .thenReturn(Mono.just("mock-access-token")); - - this.mockBackEnd.enqueue(new MockResponse() - .setBody("[]") - .addHeader("Content-Type", "application/json") - ); - - // Act - final var result = this.adminClient.getUserEffectiveClientRoles(userId, clientInternalId); - - // Assert - StepVerifier.create(result) - .expectNextMatches(List::isEmpty) - .verifyComplete(); - } -} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java new file mode 100644 index 0000000..927c95f --- /dev/null +++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakAdminReactiveClientTest.java @@ -0,0 +1,974 @@ +package com.cleverthis.authservice.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.cleverthis.authservice.config.KeycloakClientConfigProperties; +import com.cleverthis.authservice.dto.RegistrationRequest; +import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; +import com.cleverthis.authservice.exception.RegistrationException; +import com.cleverthis.authservice.exception.ServiceUnavailableException; +import com.cleverthis.authservice.exception.UserAlreadyExistsException; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.mockito.Mockito; + +class KeycloakAdminReactiveClientTest { + private KeycloakAdminReactiveClient adminClient; + private final Keycloak keycloak = mock(Keycloak.class); + + @BeforeEach + void setup() { + when(this.keycloak.realm("test-realm")).thenReturn(mock()); + + when(this.keycloak.realm("test-realm").organizations()).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups()).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users()).thenReturn(mock()); + + final var configProperties = KeycloakClientConfigProperties.builder() + .realm("test-realm") + .build(); + + this.adminClient = + Mockito.spy(new KeycloakAdminReactiveClient(this.keycloak, configProperties)); + } + + @Test + void testCreateOrganization_Success() { + // Arrange + final var orgToCreate = new OrganizationRepresentation(); + orgToCreate.setId("test-id"); + orgToCreate.setName("Test Organization"); + + final var response = mock(Response.class); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.SUCCESSFUL); + when(response.readEntity(OrganizationRepresentation.class)).thenReturn(orgToCreate); + + when(this.keycloak.realm("test-realm") + .organizations().create(orgToCreate)) + .thenReturn(response); + + // Act + final var result = this.adminClient.createOrganization(orgToCreate).block(); + + // Assert + assertNotNull(result); + assertEquals("Test Organization", result.getName()); + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations()).create(orgToCreate); + } + + @Test + void testCreateOrganization_Failure() { + // Arrange + final var orgToCreate = new OrganizationRepresentation(); + orgToCreate.setId("test-id"); + orgToCreate.setName("Test Organization"); + + final var response = mock(Response.class); + when(response.getStatusInfo()).thenReturn(mock()); + + // keycloak return non-200 + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Error occurred"); + + when( + this.keycloak.realm("test-realm").organizations() + .create(orgToCreate)) + .thenReturn(response); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.createOrganization(orgToCreate).block()); + + + // client read exception + when(response.getStatusInfo().getFamily()) + .thenReturn(Response.Status.Family.SUCCESSFUL); + when(response.readEntity(OrganizationRepresentation.class)) + .thenThrow(new ProcessingException("test")); + + when( + this.keycloak.realm("test-realm").organizations() + .create(orgToCreate)) + .thenReturn(response); + + // Act & Assert + Assertions.assertThrows(ProcessingException.class, + () -> this.adminClient.createOrganization(orgToCreate).block()); + } + + @Test + void testGetOrganizations_Success() { + // Arrange + final var organization1 = new OrganizationRepresentation(); + organization1.setId("org-1"); + organization1.setName("Organization 1"); + + final var organization2 = new OrganizationRepresentation(); + organization2.setId("org-2"); + organization2.setName("Organization 2"); + + when(this.keycloak.realm("test-realm") + .organizations().list(null, Integer.MAX_VALUE)) + .thenReturn(List.of(organization1, organization2)); + + // Act + final var result = this.adminClient.getOrganizations().block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("Organization 1", result.get(0).getName()); + assertEquals("Organization 2", result.get(1).getName()); + verify(this.keycloak.realm("test-realm").organizations()).list(null, Integer.MAX_VALUE); + } + + @Test + void testGetOrganizationById_Success() { + // Arrange + final var organization = new OrganizationRepresentation(); + organization.setId("org-1"); + organization.setName("Test Organization"); + + when(this.keycloak.realm("test-realm") + .organizations().get("org-1")) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm") + .organizations().get("org-1").toRepresentation()) + .thenReturn(organization); + + // Act + final var result = this.adminClient.getOrganizationById("org-1").block(); + + // Assert + assertNotNull(result); + assertEquals("org-1", result.getId()); + assertEquals("Test Organization", result.getName()); + verify(this.keycloak.realm("test-realm").organizations().get("org-1")) + .toRepresentation(); + } + + @Test + void testGetOrganizationById_NotFound() { + // Arrange + when(this.keycloak.realm("test-realm") + .organizations().get("non-existing-id")) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm") + .organizations().get("non-existing-id").toRepresentation()) + .thenThrow(new NotFoundException("Organization not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getOrganizationById("non-existing-id").block()); + verify(this.keycloak.realm("test-realm").organizations().get("non-existing-id")) + .toRepresentation(); + } + + @Test + void testUpdateOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.updateOrganization(orgId, orgToUpdate).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId)).update(orgToUpdate); + } + + @Test + void testUpdateOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.updateOrganization(orgId, orgToUpdate).block()); + } + + @Test + void testUpdateOrganization_Failure_ProcessingException() { + // Arrange + final var orgId = "org-1"; + final var orgToUpdate = new OrganizationRepresentation(); + orgToUpdate.setId("org-1"); + orgToUpdate.setName("Updated Organization"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).update(orgToUpdate)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)) + .thenThrow(new ProcessingException("Processing error")); + + // Act & Assert + Assertions.assertThrows(ProcessingException.class, + () -> this.adminClient.updateOrganization(orgId, orgToUpdate).block()); + } + + @Test + void testDeleteOrganization_Success() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.deleteOrganization(orgId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId)).delete(); + } + + @Test + void testDeleteOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.deleteOrganization(orgId).block()); + } + + @Test + void testDeleteOrganization_Failure_ServerError() { + // Arrange + final var orgId = "org-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).delete()) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenReturn("Server error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.deleteOrganization(orgId).block()); + } + + @Test + void testInviteUserToOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.inviteUserToOrganization(orgId, invitation).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName()); + } + + @Test + void testInviteUserToOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.inviteUserToOrganization(orgId, invitation).block()); + } + + @Test + void testInviteUserToOrganization_Failure_Exception() { + // Arrange + final var orgId = "org-1"; + final var invitation = new KeycloakInvitationRequest("test@example.com", "Test", "User"); + final var response = mock(Response.class); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .inviteUser(invitation.getEmail(), invitation.getFirstName(), + invitation.getLastName())) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.inviteUserToOrganization(orgId, invitation).block()); + } + + @Test + void testGetOrganizationMembers_Success() { + // Arrange + final var orgId = "org-1"; + + final var member1 = new MemberRepresentation(); + member1.setId("user-1"); + member1.setUsername("member1"); + + final var member2 = new MemberRepresentation(); + member2.setId("user-2"); + member2.setUsername("member2"); + + when(this.keycloak.realm("test-realm").organizations().get(orgId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId) + .members().list(null, Integer.MAX_VALUE)) + .thenReturn(List.of(member1, member2)); + + // Act + final var result = this.adminClient.getOrganizationMembers(orgId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("user-1", result.get(0).getId()); + assertEquals("user-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").organizations().get(orgId) + .members()).list(null, Integer.MAX_VALUE); + } + + @Test + void testGetOrganizationMembers_Failure_NotFound() { + // Arrange + final var orgId = "non-existing-org"; + + when(this.keycloak.realm("test-realm").organizations().get(orgId)) + .thenThrow(new NotFoundException("Organization not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getOrganizationMembers(orgId).block()); + verify(this.keycloak.realm("test-realm").organizations()).get(orgId); + } + + @Test + void testAddMemberToOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.addMemberToOrganization(orgId, userId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .addMember(userId); + } + + @Test + void testAddMemberToOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.addMemberToOrganization(orgId, userId).block()); + } + + @Test + void testAddMemberToOrganization_Failure_UnexpectedException() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .addMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.addMemberToOrganization(orgId, userId).block()); + } + + @Test + void testRemoveMemberFromOrganization_Success() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.removeMemberFromOrganization(orgId, userId).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").organizations().get(orgId).members()) + .removeMember(userId); + } + + @Test + void testRemoveMemberFromOrganization_Failure_ClientError() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.removeMemberFromOrganization(orgId, userId).block()); + } + + @Test + void testRemoveMemberFromOrganization_Failure_Exception() { + // Arrange + final var orgId = "org-1"; + final var userId = "user-1"; + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").organizations().get(orgId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members()).thenReturn( + mock()); + when(this.keycloak.realm("test-realm").organizations().get(orgId).members() + .removeMember(userId)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.removeMemberFromOrganization(orgId, userId).block()); + } + + @Test + void testCreateSubGroup_Success() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.createSubGroup(parentGroupId, subGroup).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)).subGroup(subGroup); + } + + @Test + void testCreateSubGroup_Failure_ClientError() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.readEntity(String.class)).thenReturn("Client error occurred"); + + // Act & Assert + Assertions.assertThrows(ServiceUnavailableException.class, + () -> this.adminClient.createSubGroup(parentGroupId, subGroup).block()); + } + + @Test + void testCreateSubGroup_Failure_Exception() { + // Arrange + final var parentGroupId = "parent-group-1"; + final var subGroup = new GroupRepresentation(); + subGroup.setName("Sub Group 1"); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId).subGroup(subGroup)) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenThrow(new RuntimeException("Unexpected error")); + + // Act & Assert + Assertions.assertThrows(RuntimeException.class, + () -> this.adminClient.createSubGroup(parentGroupId, subGroup).block()); + } + + @Test + void testGetGroupById_Success() { + // Arrange + final var groupId = "group-1"; + final var group = new GroupRepresentation(); + group.setId(groupId); + group.setName("Test Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation()) + .thenReturn(group); + + // Act + final var result = this.adminClient.getGroupById(groupId).block(); + + // Assert + assertNotNull(result); + assertEquals("group-1", result.getId()); + assertEquals("Test Group", result.getName()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation(); + } + + @Test + void testGetGroupById_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).toRepresentation()) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getGroupById(groupId).block()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).toRepresentation(); + } + + @Test + void testListSubGroups_Success() { + // Arrange + final var parentGroupId = "parent-group-1"; + + final var subGroup1 = new GroupRepresentation(); + subGroup1.setId("sub-group-1"); + subGroup1.setName("Sub Group 1"); + + final var subGroup2 = new GroupRepresentation(); + subGroup2.setId("sub-group-2"); + subGroup2.setName("Sub Group 2"); + + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(parentGroupId) + .getSubGroups(null, Integer.MAX_VALUE, false)) + .thenReturn(List.of(subGroup1, subGroup2)); + + // Act + final var result = this.adminClient.listSubGroups(parentGroupId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("sub-group-1", result.get(0).getId()); + assertEquals("sub-group-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").groups().group(parentGroupId)) + .getSubGroups(null, Integer.MAX_VALUE, false); + } + + @Test + void testListSubGroups_NotFound() { + // Arrange + final var parentGroupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(parentGroupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.listSubGroups(parentGroupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(parentGroupId); + } + + @Test + void testUpdateGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var groupToUpdate = new GroupRepresentation(); + groupToUpdate.setId("group-1"); + groupToUpdate.setName("Updated Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + + // Act + this.adminClient.updateGroup(groupId, groupToUpdate).block(); + + // Assert + verify(this.keycloak.realm("test-realm").groups().group(groupId)).update(groupToUpdate); + } + + @Test + void testUpdateGroup_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + final var groupToUpdate = new GroupRepresentation(); + groupToUpdate.setId("non-existing-group"); + groupToUpdate.setName("Non-existing Group"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.updateGroup(groupId, groupToUpdate).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testDeleteGroup_Success() { + // Arrange + final var groupId = "group-1"; + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + + // Act + this.adminClient.deleteGroup(groupId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").groups().group(groupId)).remove(); + } + + @Test + void testDeleteGroup_NotFound() { + // Arrange + final var groupId = "non-existing-group"; + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.deleteGroup(groupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testAddMemberToGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + + // Act + this.adminClient.addMemberToGroup(groupId, userId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").users().get(userId)).joinGroup(groupId); + } + + @Test + void testAddMemberToGroup_ClientError() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + final var userResource = mock(UserResource.class); + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource); + doThrow(new ClientErrorException(404)).when(userResource).joinGroup(groupId); + + // Act & Assert + Assertions.assertThrows(IllegalArgumentException.class, + () -> this.adminClient.addMemberToGroup(groupId, userId).block()); + } + + @Test + void testGetGroupMembers_Success() { + // Arrange + final var groupId = "group-1"; + + final var member1 = new UserRepresentation(); + member1.setId("user-1"); + member1.setUsername("member1"); + + final var member2 = new UserRepresentation(); + member2.setId("user-2"); + member2.setUsername("member2"); + + when(this.keycloak.realm("test-realm").groups().group(groupId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").groups().group(groupId).members()) + .thenReturn(List.of(member1, member2)); + + // Act + final var result = this.adminClient.getGroupMembers(groupId).block(); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals("user-1", result.get(0).getId()); + assertEquals("user-2", result.get(1).getId()); + verify(this.keycloak.realm("test-realm").groups().group(groupId)).members(); + } + + @Test + void testGetGroupMembers_GroupNotFound() { + // Arrange + final var groupId = "non-existing-group"; + + when(this.keycloak.realm("test-realm").groups().group(groupId)) + .thenThrow(new NotFoundException("Group not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getGroupMembers(groupId).block()); + verify(this.keycloak.realm("test-realm").groups()).group(groupId); + } + + @Test + void testRemoveMemberFromGroup_Success() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + + // Act + this.adminClient.removeMemberFromGroup(groupId, userId).block(); + + // Assert + verify(this.keycloak.realm("test-realm").users().get(userId)).leaveGroup(groupId); + } + + @Test + void testRemoveMemberFromGroup_Failure_ClientError() { + // Arrange + final var groupId = "group-1"; + final var userId = "user-1"; + + final var userResource = mock(UserResource.class); + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(userResource); + doThrow(new ClientErrorException(404)).when(userResource).leaveGroup(groupId); + + // Act & Assert + Assertions.assertThrows(IllegalArgumentException.class, + () -> this.adminClient.removeMemberFromGroup(groupId, userId).block()); + } + + @Test + void testRegisterUser_Success() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SUCCESSFUL); + + // Act + this.adminClient.registerUser(registrationRequest).block(); + + // Assert + //noinspection resource + verify(this.keycloak.realm("test-realm").users()) + .create(argThat(user -> user.getUsername().equals("john@example.com"))); + } + + @Test + void testRegisterUser_UserAlreadyExists() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.CLIENT_ERROR); + when(response.getStatus()).thenReturn(409); + + // Act & Assert + Assertions.assertThrows(UserAlreadyExistsException.class, + () -> this.adminClient.registerUser(registrationRequest).block()); + } + + @Test + void testRegisterUser_ServerError() { + // Arrange + final var registrationRequest = new RegistrationRequest( + "John", "Doe", "john@example.com", "password123" + ); + + final var response = mock(Response.class); + when(this.keycloak.realm("test-realm").users().create(any(UserRepresentation.class))) + .thenReturn(response); + when(response.getStatusInfo()).thenReturn(mock()); + when(response.getStatusInfo().getFamily()).thenReturn(Response.Status.Family.SERVER_ERROR); + when(response.readEntity(String.class)).thenReturn("Internal Server Error"); + + // Act & Assert + Assertions.assertThrows(RegistrationException.class, + () -> this.adminClient.registerUser(registrationRequest).block()); + } + + @Test + void testGetUserDetails_Success() { + // Arrange + final var userId = "user-1"; + final var userRepresentation = new UserRepresentation(); + userRepresentation.setId(userId); + userRepresentation.setUsername("user1"); + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation()) + .thenReturn(userRepresentation); + + // Act + final var result = this.adminClient.getUserDetails(userId).block(); + + // Assert + assertNotNull(result); + assertEquals("user-1", result.getId()); + assertEquals("user1", result.getUsername()); + verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation(); + } + + @Test + void testGetUserDetails_UserNotFound() { + // Arrange + final var userId = "non-existing-user"; + + when(this.keycloak.realm("test-realm").users().get(userId)).thenReturn(mock()); + when(this.keycloak.realm("test-realm").users().get(userId).toRepresentation()) + .thenThrow(new NotFoundException("User not found")); + + // Act & Assert + Assertions.assertThrows(NoSuchElementException.class, + () -> this.adminClient.getUserDetails(userId).block()); + verify(this.keycloak.realm("test-realm").users().get(userId)).toRepresentation(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java index c84fa4a..fc9f76a 100644 --- a/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java +++ b/src/test/java/com/cleverthis/authservice/service/impl/KeycloakIdentityManagerServiceTest.java @@ -1,28 +1,42 @@ package com.cleverthis.authservice.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import com.cleverthis.authservice.config.KeycloakClientConfigProperties; import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.dto.group.CreateGroupRequest; +import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest; +import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest; +import com.cleverthis.authservice.dto.organization.InviteUserRequest; +import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest; import com.cleverthis.authservice.exception.AuthenticationException; import com.cleverthis.authservice.exception.TokenVerificationException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.util.List; +import java.util.Map; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.MemberRepresentation; +import org.keycloak.representations.idm.OrganizationDomainRepresentation; +import org.keycloak.representations.idm.OrganizationRepresentation; +import org.keycloak.representations.idm.UserRepresentation; import org.mockito.Mockito; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; class KeycloakIdentityManagerServiceTest { private MockWebServer mockBackEnd; - private KeycloakAdminClient adminClient; + private KeycloakAdminReactiveClient adminClient; private KeycloakIdentityManagerService service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -40,7 +54,7 @@ class KeycloakIdentityManagerServiceTest { .clientId("test-client") .clientSecret("client-password") .build(); - this.adminClient = Mockito.mock(KeycloakAdminClient.class); + this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class); this.service = Mockito.spy(new KeycloakIdentityManagerService( webClient, this.adminClient, configProperties)); @@ -246,4 +260,375 @@ class KeycloakIdentityManagerServiceTest { .expectNext(false) .verifyComplete(); } + + @Test + void createOrganization_success() { + // Arrange + final var request = new CreateOrganizationRequest( + "Test Organization", List.of("test.org"), "", Map.of() + ); + final var mockResponse = new OrganizationRepresentation(); + mockResponse.setId("123"); + mockResponse.setName("Test Organization"); + mockResponse.addDomain(new OrganizationDomainRepresentation("test.org")); + mockResponse.setDescription(""); + mockResponse.setEnabled(true); + + Mockito.when(this.adminClient.createOrganization(any())) + .thenReturn(Mono.just(mockResponse)); + + // Act + final var result = this.service.createOrganization(request); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "123".equals(response.getId()) && + "Test Organization".equals(response.getName())) + .verifyComplete(); + } + + @Test + void getOrganizations_success() { + // Arrange + final var mockOrganizations = List.of( + new OrganizationRepresentation() {{ + setId("org1"); + setName("Organization 1"); + addDomain(new OrganizationDomainRepresentation("domain1.org")); + }}, + new OrganizationRepresentation() {{ + setId("org2"); + setName("Organization 2"); + addDomain(new OrganizationDomainRepresentation("domain2.org")); + }} + ); + + Mockito.when(this.adminClient.getOrganizations()) + .thenReturn(Mono.just(mockOrganizations)); + + // Act + final var result = this.service.getOrganizations(); + + // Assert + StepVerifier.create(result) + .expectNextMatches(orgList -> orgList.size() == 2 + && "Organization 1".equals(orgList.getFirst().getName()) + && "domain1.org".equals( + orgList.getFirst().getDomains().iterator().next().getName()) + && "Organization 2".equals(orgList.get(1).getName())) + .verifyComplete(); + } + + @Test + void getOrganizationById_success() { + // Arrange + final var orgId = "abc123"; + final var orgRepresentation = new OrganizationRepresentation(); + orgRepresentation.setId(orgId); + orgRepresentation.setName("Test Organization"); + orgRepresentation.addDomain(new OrganizationDomainRepresentation("test.org")); + orgRepresentation.setDescription("Test Description"); + orgRepresentation.setEnabled(true); + + Mockito.when(this.adminClient.getOrganizationById(orgId)) + .thenReturn(Mono.just(orgRepresentation)); + + // Act + final var result = this.service.getOrganizationById(orgId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "abc123".equals(response.getId()) + && "Test Organization".equals(response.getName()) + && "Test Description".equals(response.getDescription()) + && Boolean.TRUE.equals(response.getEnabled()) + && response.getDomains().iterator().next().getName().equals("test.org")) + .verifyComplete(); + } + + @Test + void updateOrganization_success() { + // Arrange + final var orgId = "org123"; + final var updateRequest = new UpdateOrganizationRequest( + List.of("updated.org"), "Updated Description", Map.of("key", List.of("value")) + ); + final var existingOrg = new OrganizationRepresentation(); + existingOrg.setId(orgId); + existingOrg.setDescription("Old Description"); + existingOrg.addDomain(new OrganizationDomainRepresentation("old.org")); + + Mockito.when(this.adminClient.getOrganizationById(orgId)) + .thenReturn(Mono.just(existingOrg)); + Mockito.when(this.adminClient.updateOrganization(orgId, existingOrg)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.updateOrganization(orgId, updateRequest); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).updateOrganization(orgId, existingOrg); + assertEquals("Updated Description", existingOrg.getDescription()); + assertEquals("value", existingOrg.getAttributes().get("key").getFirst()); + assertEquals(1, existingOrg.getDomains().size()); + assertEquals("updated.org", existingOrg.getDomains().iterator().next().getName()); + } + + @Test + void deleteOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + + Mockito.when(this.adminClient.deleteOrganization(orgId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.deleteOrganization(orgId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).deleteOrganization(orgId); + } + + @Test + void inviteUserToOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var request = new InviteUserRequest("test@example.com", "John", "Wick"); + final var invitationRequest = new KeycloakInvitationRequest(); + invitationRequest.setEmail(request.getEmail()); + invitationRequest.setFirstName(request.getFirstName()); + invitationRequest.setLastName(request.getLastName()); + + Mockito.when(this.adminClient.inviteUserToOrganization(any(), any())) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.inviteUserToOrganization(orgId, request); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).inviteUserToOrganization(orgId, invitationRequest); + } + + @Test + void getOrganizationMembers_success() { + // Arrange + final var orgId = "test-org-id"; + final var mockMembers = List.of( + // I asked Gemini, and this is an antipattern that no one mentions + // when I learned Java. Basically, it will create a new anonymous + // class for every instance. In this case, we have two. + // Gemini suggests we use a method to create a user and then put it in the list, + // but a dedicated method is not as compact as double brace initialization. + // This test is generated by gemini 2.5 pro. I think it's ok to use here + // because it's a unit test, and I blame keycloak to not offer a builder. + // DO NOT use this pattern in the main source set. + new MemberRepresentation() {{ + setId("user1"); + setUsername("user1Username"); + setFirstName("User1"); + setLastName("One"); + setEmail("user1@example.com"); + setEnabled(true); + }}, + new MemberRepresentation() {{ + setId("user2"); + setUsername("user2Username"); + setFirstName("User2"); + setLastName("Two"); + setEmail("user2@example.com"); + setEnabled(true); + }} + ); + + Mockito.when(this.adminClient.getOrganizationMembers(orgId)) + .thenReturn(Mono.just(mockMembers)); + + // Act + final var result = this.service.getOrganizationMembers(orgId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(members -> members.size() == 2 + && "user1".equals(members.getFirst().getId()) + && "user1@example.com".equals(members.getFirst().getEmail()) + && "user2".equals(members.get(1).getId()) + && "user2@example.com".equals(members.get(1).getEmail())) + .verifyComplete(); + + Mockito.verify(this.adminClient).getOrganizationMembers(orgId); + } + + @Test + void addMemberToOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var userId = "test-user-id"; + + Mockito.when(this.adminClient.addMemberToOrganization(orgId, userId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.addMemberToOrganization(orgId, userId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).addMemberToOrganization(orgId, userId); + } + + @Test + void removeMemberFromOrganization_success() { + // Arrange + final var orgId = "test-org-id"; + final var userId = "test-user-id"; + + Mockito.when(this.adminClient.removeMemberFromOrganization(orgId, userId)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.removeMemberFromOrganization(orgId, userId); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + + Mockito.verify(this.adminClient).removeMemberFromOrganization(orgId, userId); + } + + @Test + void createSubGroup_success() { + // Arrange + final var parentGroupId = "parent-group-id"; + final var request = new CreateGroupRequest( + "Test SubGroup", "", Map.of("key", List.of("value"))); + final var mockGroupRepresentation = new GroupRepresentation(); + mockGroupRepresentation.setId("sub-group-id"); + mockGroupRepresentation.setName(request.getName()); + mockGroupRepresentation.setAttributes(request.getAttributes()); + + Mockito.when(this.adminClient.createSubGroup(parentGroupId, mockGroupRepresentation)) + .thenReturn(Mono.empty()); + + // Act + final var result = this.service.createSubGroup(parentGroupId, request); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> "Test SubGroup".equals(response.getName()) && + response.getAttributes().get("key").contains("value")) + .verifyComplete(); + + Mockito.verify(this.adminClient).createSubGroup(Mockito.eq(parentGroupId), Mockito.any()); + } + + @Test + void getGroupById_success() { + // Arrange + final var groupId = "test-group-id"; + final var mockGroup = new GroupRepresentation(); + mockGroup.setId(groupId); + mockGroup.setName("Test Group"); + mockGroup.setPath("/test-group"); + mockGroup.setDescription("Test Group Description"); + mockGroup.setAttributes(Map.of("key", List.of("value1", "value2"))); + + Mockito.when(this.adminClient.getGroupById(groupId)) + .thenReturn(Mono.just(mockGroup)); + + // Act + final var result = this.service.getGroupById(groupId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(response -> response.getId().equals(groupId) && + response.getName().equals("Test Group") && + response.getPath().equals("/test-group") && + response.getDescription().equals("Test Group Description") && + response.getAttributes().get("key").contains("value1")) + .verifyComplete(); + + Mockito.verify(this.adminClient).getGroupById(groupId); + } + + @Test + void listSubGroups_success() { + // Arrange + final var parentGroupId = "parent-group-id"; + final var subGroups = List.of( + new GroupRepresentation() {{ + setId("sub-group-1"); + setName("SubGroup1"); + }}, + new GroupRepresentation() {{ + setId("sub-group-2"); + setName("SubGroup2"); + }} + ); + + Mockito.when(this.adminClient.listSubGroups(parentGroupId)) + .thenReturn(Mono.just(subGroups)); + + // Act + final var result = this.service.listSubGroups(parentGroupId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(groups -> groups.size() == 2 && + "SubGroup1".equals(groups.getFirst().getName()) && + "sub-group-2".equals(groups.get(1).getId())) + .verifyComplete(); + + Mockito.verify(this.adminClient).listSubGroups(parentGroupId); + } + + @Test + void getGroupMembers_success() { + // Arrange + final var groupId = "test-group-id"; + final var mockMembers = List.of( + new UserRepresentation() {{ + setId("user1"); + setUsername("user1Username"); + setFirstName("User1"); + setLastName("One"); + setEmail("user1@example.com"); + setEnabled(true); + }}, + new UserRepresentation() {{ + setId("user2"); + setUsername("user2Username"); + setFirstName("User2"); + setLastName("Two"); + setEmail("user2@example.com"); + setEnabled(true); + }} + ); + + Mockito.when(this.adminClient.getGroupMembers(groupId)) + .thenReturn(Mono.just(mockMembers)); + + // Act + final var result = this.service.getGroupMembers(groupId); + + // Assert + StepVerifier.create(result) + .expectNextMatches(members -> members.size() == 2 + && "user1".equals(members.getFirst().getId()) + && "user1@example.com".equals(members.getFirst().getEmail()) + && "user2".equals(members.get(1).getId()) + && "user2@example.com".equals(members.get(1).getEmail())) + .verifyComplete(); + + Mockito.verify(this.adminClient).getGroupMembers(groupId); + } } \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..e44914f --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,12 @@ +spring: + cloud: + consul: + config: + enabled: false +otel: + logs: + exporter: none + traces: + exporter: none + metrics: + exporter: none \ No newline at end of file