Compare commits
1 Commits
973a185f13
...
bed3de76dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
bed3de76dd
|
-14
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<fileset-config file-format-version="1.2.0" simple-config="false" sync-formatter="false">
|
||||
<local-check-config name="maven-checkstyle-plugin validate" location="file:/D:/vmware/micro-code/user-management/checkstyle.xml" type="remote" description="maven-checkstyle-plugin configuration validate">
|
||||
<property name="checkstyle.header.file" value="D:\vmware\micro-code\.metadata\.plugins\org.eclipse.core.resources\.projects\user-management\com.basistech.m2e.code.quality.checkstyleConfigurator\checkstyle-header-validate.txt"/>
|
||||
<property name="checkstyle.cache.file" value="${project_loc}/target/checkstyle-cachefile"/>
|
||||
</local-check-config>
|
||||
<fileset name="java-sources-validate" enabled="true" check-config-name="maven-checkstyle-plugin validate" local="true">
|
||||
<file-match-pattern match-pattern="^src/main/java/.*\.java" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
|
||||
<file-match-pattern match-pattern="^src/test/resources.*\.properties" include-pattern="true"/>
|
||||
</fileset>
|
||||
</fileset-config>
|
||||
@@ -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 '<counter type="LINE" [^>]*>' target/site/jacoco/jacoco.xml | tail -1)
|
||||
INPUT=$(grep -o '<counter type="LINE" [^>]*>' 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"
|
||||
|
||||
@@ -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: |
|
||||
|
||||
+32
-46
@@ -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/
|
||||
|
||||
+3
-3
@@ -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"]
|
||||
@@ -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<Test> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -1,161 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.cleverthis</groupId>
|
||||
<artifactId>auth-service</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>auth-service</name>
|
||||
<description>User management project for CleverThis</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<start-class>com.cleverthis.authservice.AuthServiceApplication</start-class>
|
||||
<spring-cloud.version>2024.0.1</spring-cloud.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-consul-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>4.12.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.13</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>prepare-package</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.23.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<linkXRef>false</linkXRef>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "auth-service"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
@@ -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.
|
||||
* <p>
|
||||
* Default: CPU cores * 2, since the workload for auth-service is mainly IO-intensive.
|
||||
* </p>
|
||||
* <p>Set to -1 to use the default value.</p>
|
||||
* */
|
||||
@Builder.Default
|
||||
private int initialAvailability = Runtime.getRuntime().availableProcessors() * 2;
|
||||
|
||||
/**
|
||||
* Docker swarm service id.
|
||||
*/
|
||||
private String swarmServiceId;
|
||||
|
||||
/**
|
||||
* Docker swarm task id.
|
||||
*/
|
||||
private String swarmTaskId;
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PassThrough> passthroughs = new ArrayList<>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> getTargetServiceClientId(String fullUriString) {
|
||||
private Optional<PermissionMappingConfigProperties.Rule> 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<PermissionMappingConfigProperties.Rule> 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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<String, JsonNode> args;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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 <T> EndpointOkResponse ok(final T result) {
|
||||
return EndpointOkResponse.builder()
|
||||
.result(List.of(result))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ok response from a list of results.
|
||||
*/
|
||||
public static <T> EndpointOkResponse ok(final Iterable<T> result) {
|
||||
final var list = new LinkedList<T>();
|
||||
result.forEach(list::add);
|
||||
return EndpointOkResponse.builder()
|
||||
.result(list)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ok response from an array of results.
|
||||
*/
|
||||
public static <T> 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();
|
||||
}
|
||||
}
|
||||
+70
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
-18
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String> 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<PermissionMappingConfigProperties.Rule> matchRuleByClientIdAndPath(
|
||||
String clientId, String path);
|
||||
|
||||
/**
|
||||
* Return the list of pass-through rules by client id.
|
||||
*/
|
||||
List<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
|
||||
String clientId);
|
||||
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
|
||||
}
|
||||
|
||||
+2
-26
@@ -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<String> matchClientIdByPath(final String path) {
|
||||
public Optional<PermissionMappingConfigProperties.Rule> 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<PermissionMappingConfigProperties.Rule> 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<PermissionMappingConfigProperties.PassThrough> getPassThoughRulesByClientId(
|
||||
final String clientId
|
||||
) {
|
||||
final var config = getConfig();
|
||||
return config.getRules().stream()
|
||||
.filter(rule -> rule.getKeycloakClientId().equals(clientId))
|
||||
.flatMap(rule -> rule.getPassthroughs().stream())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> adminAccessToken = new AtomicReference<>();
|
||||
private final AtomicReference<Long> 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<String> 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<String, String> 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<String> 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<List<KeycloakRoleRepresentation>> 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<KeycloakOrganizationRepresentation> 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<List<KeycloakOrganizationRepresentation>> 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<KeycloakOrganizationRepresentation> 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<Void> 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<Void> 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<Void> inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) {
|
||||
// Create form data from the invitation DTO
|
||||
MultiValueMap<String, String> 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<List<KeycloakUserRepresentation>> 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<Void> 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<Void> 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<Void> 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<KeycloakGroupRepresentation> 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<List<KeycloakGroupRepresentation>> 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<Void> 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<Void> 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<Void> 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<List<KeycloakUserRepresentation>> 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<Void> 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));
|
||||
}
|
||||
|
||||
}
|
||||
+473
@@ -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<OrganizationRepresentation> 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<List<OrganizationRepresentation>> 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<OrganizationRepresentation> 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<Void> 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<Void> 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<Void> 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<List<MemberRepresentation>> 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<Void> 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<Void> 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<Void> 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<GroupRepresentation> getGroupById(String groupId) {
|
||||
return Mono.create((Consumer<MonoSink<GroupRepresentation>>) 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<List<GroupRepresentation>> listSubGroups(String parentGroupId) {
|
||||
return Mono.create((Consumer<MonoSink<List<GroupRepresentation>>>) 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<Void> updateGroup(String groupId, GroupRepresentation group) {
|
||||
return Mono
|
||||
.create((Consumer<MonoSink<Void>>) 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<Void> deleteGroup(String groupId) {
|
||||
return Mono
|
||||
.create((Consumer<MonoSink<Void>>) 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<Void> 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<MonoSink<Void>>) 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<List<UserRepresentation>> getGroupMembers(String groupId) {
|
||||
return Mono.create((Consumer<MonoSink<List<UserRepresentation>>>) 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<Void> removeMemberFromGroup(String groupId, String userId) {
|
||||
return Mono
|
||||
.create((Consumer<MonoSink<Void>>) 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<Void> 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<UserRepresentation> getUserDetails(String userId) {
|
||||
return Mono.create((Consumer<MonoSink<UserRepresentation>>) 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));
|
||||
}
|
||||
|
||||
}
|
||||
+127
-199
@@ -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<Void> registerUser(RegistrationRequest registrationRequest) {
|
||||
log.info("Registering new user with email: {}", registrationRequest.getEmail());
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<OrganizationResponse> 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<OrganizationDomainRepresentation> 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<List<OrganizationResponse>> getOrganizations() {
|
||||
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
|
||||
.map(this::toOrganizationResponse).collect(Collectors.toList()));
|
||||
.map(this::toOrganizationResponse).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<OrganizationResponse> 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<OrganizationDomainRepresentation> 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<List<OrganizationMemberResponse>> 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<GroupResponse> createSubGroup(final String parentGroupId,
|
||||
final CreateGroupRequest request) {
|
||||
KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation();
|
||||
public Mono<GroupResponse> 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<List<GroupResponse>> 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<Void> 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<Void> deleteGroup(final String groupId) {
|
||||
return this.keycloakAdminClient.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<Void> addMemberToGroup(final String groupId, final String userId) {
|
||||
return this.keycloakAdminClient.addMemberToGroup(groupId, userId);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<List<OrganizationMemberResponse>> 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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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<OrganizationMemberResponse> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -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",
|
||||
|
||||
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
+119
@@ -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<String, ?> args) {
|
||||
final String json;
|
||||
try {
|
||||
json = this.objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(args);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
final LinkedHashMap<String, JsonNode> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-33
@@ -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"),
|
||||
() -> {}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+974
@@ -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();
|
||||
}
|
||||
}
|
||||
+387
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
spring:
|
||||
cloud:
|
||||
consul:
|
||||
config:
|
||||
enabled: false
|
||||
otel:
|
||||
logs:
|
||||
exporter: none
|
||||
traces:
|
||||
exporter: none
|
||||
metrics:
|
||||
exporter: none
|
||||
Reference in New Issue
Block a user