Implement query user endpoint for RabbitMQ (#35)
This PR implemented ticket clevermicro/user-management#34 for the user-service-v1 endpoint, along with all the infrastructures required to support the endpoints. The changes are: + switch build tool to gradle for easier access to the private artifacts of clevermicro (although later Brian change the artifacts to public) + set up clevermicro amqp client + switched to official keycloak admin client instead of implementing it with webflux + implement the message formats for the RPC protocol, for now they are exclusive to auth-service, but in the future we will reuse (might with some changes) them for all RPC calls inside clevermicro (need a server lib for handling the message parsing and convertion) + implement the user query endpoint + implement unit test so the overall coverage is above 85% + fixed all issues found during the testing phase with shared env Currently the latest docker image has been deployed to the shared env, see https://docs.cleverthis.com/en/architecture/microservices/shared-env#multi-user-support Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
-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
|
# need to setup node and git
|
||||||
- run: |
|
- run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y nodejs git curl maven
|
apt-get install -y nodejs git curl
|
||||||
# check out code
|
# check out code
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: https://github.com/actions/setup-java@v4
|
- uses: https://github.com/actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
# ensure executable
|
||||||
|
- run: chmod +x ./gradlew
|
||||||
# run gradle test
|
# run gradle test
|
||||||
- run: mvn clean test jacoco:report
|
- run: ./gradlew check --no-daemon
|
||||||
|
env:
|
||||||
|
MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
# process jacoco report
|
# process jacoco report
|
||||||
- name: Collect coverage report
|
- name: Collect coverage report
|
||||||
id: coverage
|
id: coverage
|
||||||
run: |
|
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')
|
MISSED=$(echo "$INPUT" | sed -n 's/.*missed="\([0-9]*\)".*/\1/p')
|
||||||
COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p')
|
COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p')
|
||||||
echo "MISSED: $MISSED"
|
echo "MISSED: $MISSED"
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "21"
|
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
|
# setup docker
|
||||||
- name: set up docker cli
|
- name: set up docker cli
|
||||||
run: |
|
run: |
|
||||||
@@ -74,6 +83,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
# file: ci.Dockerfile
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
+32
-46
@@ -1,50 +1,36 @@
|
|||||||
# Useful examples
|
.gradle
|
||||||
# https://github.com/github/gitignore
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
# OS X
|
### STS ###
|
||||||
*.DS_Store
|
.apt_generated
|
||||||
|
|
||||||
# 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/
|
|
||||||
.classpath
|
.classpath
|
||||||
|
.factorypath
|
||||||
.project
|
.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
|
WORKDIR /code
|
||||||
COPY . /code
|
COPY . /code
|
||||||
RUN mvn clean package spring-boot:repackage
|
RUN gradle bootJar --no-daemon
|
||||||
|
|
||||||
FROM azul/zulu-openjdk:21-latest
|
FROM azul/zulu-openjdk:21-latest
|
||||||
EXPOSE 8099
|
EXPOSE 8099
|
||||||
WORKDIR /app
|
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"]
|
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;
|
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.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config for keycloak clients:
|
* Config for keycloak clients:
|
||||||
* {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
|
* {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
|
||||||
* and {@link com.cleverthis.authservice.service.impl.KeycloakAdminClient}.
|
* and {@link KeycloakAdminReactiveClient}.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
|
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
|
||||||
public class KeycloakClientConfig {
|
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")
|
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
|
||||||
private String keycloakClientId;
|
private String keycloakClientId;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
private List<PassThrough> passthroughs = new ArrayList<>();
|
private List<PassThrough> passthroughs = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,17 +187,19 @@ public class AuthController {
|
|||||||
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
|
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
|
||||||
originalUriString);
|
originalUriString);
|
||||||
// first detect the client id and service relative path
|
// first detect the client id and service relative path
|
||||||
final var targetServiceClientIdOpt = getTargetServiceClientId(originalUriString);
|
final var optionalRule = getPermissionCheckRuleByUri(originalUriString);
|
||||||
if (targetServiceClientIdOpt.isEmpty()) {
|
if (optionalRule.isEmpty()) {
|
||||||
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
|
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
|
||||||
originalUriString);
|
originalUriString);
|
||||||
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
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 =
|
final var serviceRelativePath =
|
||||||
extractServiceRelativePath(originalUriString, targetServiceClientId);
|
extractServiceRelativePath(originalUriString, rule);
|
||||||
// then check pass-through rule
|
// then check pass-through rule
|
||||||
if (shouldPassThrough(targetServiceClientId, serviceRelativePath)) {
|
if (shouldPassThrough(rule, serviceRelativePath)) {
|
||||||
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
|
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
|
||||||
}
|
}
|
||||||
if (authorizationHeader == null) {
|
if (authorizationHeader == null) {
|
||||||
@@ -276,16 +278,15 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldPassThrough(
|
private boolean shouldPassThrough(
|
||||||
final String serviceClientId, final String serviceRelativePath
|
final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
|
||||||
) {
|
) {
|
||||||
final var rules = this.permissionMapLookupService
|
final var passthroughs = rule.getPassthroughs();
|
||||||
.getPassThoughRulesByClientId(serviceClientId);
|
|
||||||
try {
|
try {
|
||||||
return rules.stream()
|
return passthroughs.stream()
|
||||||
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
|
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
|
||||||
} catch (final Throwable throwable) {
|
} catch (final Throwable throwable) {
|
||||||
log.error("Error executing pass-through rule for client {} : {}",
|
log.error("Error executing pass-through rule for client {} : {}",
|
||||||
serviceClientId, throwable.getMessage(), throwable);
|
rule.getKeycloakClientId(), throwable.getMessage(), throwable);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,13 +301,15 @@ public class AuthController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<String> getTargetServiceClientId(String fullUriString) {
|
private Optional<PermissionMappingConfigProperties.Rule> getPermissionCheckRuleByUri(
|
||||||
|
String fullUriString
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
URI uri = new URI(fullUriString);
|
URI uri = new URI(fullUriString);
|
||||||
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
|
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
|
||||||
log.debug("Mapping URI path: {}", path);
|
log.debug("Mapping URI path: {}", path);
|
||||||
|
|
||||||
final var pathMatch = this.permissionMapLookupService.matchClientIdByPath(path);
|
final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path);
|
||||||
if (pathMatch.isPresent()) {
|
if (pathMatch.isPresent()) {
|
||||||
return pathMatch;
|
return pathMatch;
|
||||||
}
|
}
|
||||||
@@ -315,7 +318,9 @@ public class AuthController {
|
|||||||
log.debug(
|
log.debug(
|
||||||
"No rule matched for path '{}', using default Keycloak Client ID:{}",
|
"No rule matched for path '{}', using default Keycloak Client ID:{}",
|
||||||
path, defaultValue);
|
path, defaultValue);
|
||||||
return Optional.of(defaultValue);
|
return Optional.of(PermissionMappingConfigProperties.Rule.builder()
|
||||||
|
.keycloakClientId(defaultValue)
|
||||||
|
.build());
|
||||||
}
|
}
|
||||||
} catch (URISyntaxException e) {
|
} catch (URISyntaxException e) {
|
||||||
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
|
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
|
||||||
@@ -324,17 +329,11 @@ public class AuthController {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractServiceRelativePath(String fullUriString, String mappedKeycloakClientId) {
|
private String extractServiceRelativePath(
|
||||||
// Find the rule that gave us this mappedKeycloakClientId to get its pathPrefix
|
String fullUriString, PermissionMappingConfigProperties.Rule rule
|
||||||
Optional<PermissionMappingConfigProperties.Rule> matchedRule =
|
) {
|
||||||
this.permissionMapLookupService.matchRuleByClientIdAndPath(
|
String pathPrefixToRemove = rule.getPathPrefix();
|
||||||
mappedKeycloakClientId, fullUriString
|
if (rule.getKeycloakClientId()
|
||||||
);
|
|
||||||
|
|
||||||
String pathPrefixToRemove = "";
|
|
||||||
if (matchedRule.isPresent()) {
|
|
||||||
pathPrefixToRemove = matchedRule.get().getPathPrefix();
|
|
||||||
} else if (mappedKeycloakClientId
|
|
||||||
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
|
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
|
||||||
// If it's a default mapping, there's no prefix to remove, use the whole path.
|
// 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
|
// 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.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
|
* 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,12 +1,12 @@
|
|||||||
package com.cleverthis.authservice.dto.organization;
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for representing an Organization in API responses.
|
* DTO for representing an Organization in API responses.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
|||||||
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||||
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.cleverthis.authservice.service;
|
package com.cleverthis.authservice.service;
|
||||||
|
|
||||||
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
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.
|
* Given the path, find the client id of the first rule matching the path prefix.
|
||||||
*/
|
*/
|
||||||
Optional<String> matchClientIdByPath(String path);
|
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-26
@@ -5,7 +5,6 @@ import com.cleverthis.authservice.service.PermissionMapLookupService;
|
|||||||
import com.ecwid.consul.v1.ConsulClient;
|
import com.ecwid.consul.v1.ConsulClient;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -102,38 +101,15 @@ public final class FallbackPermissionMapLookupService implements PermissionMapLo
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<String> matchClientIdByPath(final String path) {
|
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(final String path) {
|
||||||
final var config = getConfig();
|
final var config = getConfig();
|
||||||
for (final var rule : config.getRules()) {
|
for (final var rule : config.getRules()) {
|
||||||
if (path.startsWith(rule.getPathPrefix())) {
|
if (path.startsWith(rule.getPathPrefix())) {
|
||||||
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
|
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
|
||||||
path, rule.getPathPrefix(), rule.getKeycloakClientId());
|
path, rule.getPathPrefix(), rule.getKeycloakClientId());
|
||||||
return Optional.of(rule.getKeycloakClientId());
|
return Optional.of(rule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Optional.empty();
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+34
-106
@@ -7,11 +7,7 @@ import com.cleverthis.authservice.dto.VerificationResponse;
|
|||||||
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
import com.cleverthis.authservice.dto.group.GroupResponse;
|
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||||
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
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.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.CreateOrganizationRequest;
|
||||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
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.AuthenticationException;
|
||||||
import com.cleverthis.authservice.exception.LogoutException;
|
import com.cleverthis.authservice.exception.LogoutException;
|
||||||
import com.cleverthis.authservice.exception.PermissionCheckException;
|
import com.cleverthis.authservice.exception.PermissionCheckException;
|
||||||
import com.cleverthis.authservice.exception.RegistrationException;
|
|
||||||
import com.cleverthis.authservice.exception.ServiceUnavailableException;
|
import com.cleverthis.authservice.exception.ServiceUnavailableException;
|
||||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
|
|
||||||
import com.cleverthis.authservice.service.IdentityManagerService;
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.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.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -42,7 +37,6 @@ import org.springframework.util.LinkedMultiValueMap;
|
|||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.reactive.function.BodyInserters;
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@@ -55,7 +49,7 @@ import reactor.core.publisher.Mono;
|
|||||||
public class KeycloakIdentityManagerService implements IdentityManagerService {
|
public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final KeycloakAdminClient keycloakAdminClient; // Inject KeycloakAdminClient
|
private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient
|
||||||
|
|
||||||
private final String keycloakServerUrl;
|
private final String keycloakServerUrl;
|
||||||
private final String realm;
|
private final String realm;
|
||||||
@@ -83,20 +77,13 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
.encode().toUriString();
|
.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.
|
* Create a keycloak OIDC client.
|
||||||
*/
|
*/
|
||||||
@Autowired
|
@Autowired
|
||||||
public KeycloakIdentityManagerService(
|
public KeycloakIdentityManagerService(
|
||||||
final WebClient keycloakWebClient,
|
@Qualifier("keycloakWebClient") final WebClient keycloakWebClient,
|
||||||
final KeycloakAdminClient keycloakAdminClient,
|
final KeycloakAdminReactiveClient keycloakAdminClient,
|
||||||
final KeycloakClientConfigProperties configProperties
|
final KeycloakClientConfigProperties configProperties
|
||||||
) {
|
) {
|
||||||
this.webClient = keycloakWebClient;
|
this.webClient = keycloakWebClient;
|
||||||
@@ -208,78 +195,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
||||||
log.info("Registering new user with email: {}", registrationRequest.getEmail());
|
return this.keycloakAdminClient.registerUser(registrationRequest);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -331,7 +247,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
|
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
|
||||||
KeycloakOrganizationRepresentation newOrg = new KeycloakOrganizationRepresentation();
|
OrganizationRepresentation newOrg = new OrganizationRepresentation();
|
||||||
newOrg.setName(request.getName());
|
newOrg.setName(request.getName());
|
||||||
newOrg.setDescription(request.getDescription());
|
newOrg.setDescription(request.getDescription());
|
||||||
newOrg.setAttributes(request.getAttributes());
|
newOrg.setAttributes(request.getAttributes());
|
||||||
@@ -340,9 +256,13 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
if (request.getDomains() != null) {
|
if (request.getDomains() != null) {
|
||||||
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||||
.stream()
|
.stream()
|
||||||
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
|
.map(domainName -> {
|
||||||
|
final var domain = new OrganizationDomainRepresentation(domainName);
|
||||||
|
domain.setVerified(false);
|
||||||
|
return domain;
|
||||||
|
})
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
newOrg.setDomains(domainRepresentations);
|
domainRepresentations.forEach(newOrg::addDomain);
|
||||||
}
|
}
|
||||||
return this.keycloakAdminClient.createOrganization(newOrg)
|
return this.keycloakAdminClient.createOrganization(newOrg)
|
||||||
.map(this::toOrganizationResponse);
|
.map(this::toOrganizationResponse);
|
||||||
@@ -371,9 +291,16 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
if (request.getDomains() != null) {
|
if (request.getDomains() != null) {
|
||||||
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||||
.stream()
|
.stream()
|
||||||
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
|
.map(domainName -> {
|
||||||
|
final var domain = new OrganizationDomainRepresentation(domainName);
|
||||||
|
domain.setVerified(false);
|
||||||
|
return domain;
|
||||||
|
})
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
existingOrg.setDomains(domainRepresentations);
|
if (existingOrg.getDomains() != null) {
|
||||||
|
existingOrg.getDomains().clear();
|
||||||
|
}
|
||||||
|
domainRepresentations.forEach(existingOrg::addDomain);
|
||||||
}
|
}
|
||||||
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
|
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
|
||||||
});
|
});
|
||||||
@@ -411,9 +338,10 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<GroupResponse> createSubGroup(final String parentGroupId,
|
public Mono<GroupResponse> createSubGroup(
|
||||||
final CreateGroupRequest request) {
|
final String parentGroupId, final CreateGroupRequest request
|
||||||
KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation();
|
) {
|
||||||
|
GroupRepresentation newGroup = new GroupRepresentation();
|
||||||
newGroup.setName(request.getName());
|
newGroup.setName(request.getName());
|
||||||
newGroup.setAttributes(request.getAttributes());
|
newGroup.setAttributes(request.getAttributes());
|
||||||
|
|
||||||
@@ -437,7 +365,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
|
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
|
||||||
KeycloakGroupRepresentation groupUpdate = new KeycloakGroupRepresentation();
|
final var groupUpdate = new GroupRepresentation();
|
||||||
groupUpdate.setAttributes(request.getAttributes());
|
groupUpdate.setAttributes(request.getAttributes());
|
||||||
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
|
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
|
||||||
}
|
}
|
||||||
@@ -466,7 +394,7 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Add DTO Mapper for Group ---
|
// --- Add DTO Mapper for Group ---
|
||||||
private GroupResponse toGroupResponse(final KeycloakGroupRepresentation keycloakGroup) {
|
private GroupResponse toGroupResponse(final GroupRepresentation keycloakGroup) {
|
||||||
if (keycloakGroup == null) {
|
if (keycloakGroup == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -482,17 +410,17 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
|
|||||||
// --- DTO Mapper Helper Methods ---
|
// --- DTO Mapper Helper Methods ---
|
||||||
|
|
||||||
private OrganizationResponse toOrganizationResponse(
|
private OrganizationResponse toOrganizationResponse(
|
||||||
KeycloakOrganizationRepresentation keycloakOrg) {
|
OrganizationRepresentation keycloakOrg) {
|
||||||
if (keycloakOrg == null) {
|
if (keycloakOrg == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
|
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
|
||||||
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.getEnabled(),
|
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.isEnabled(),
|
||||||
keycloakOrg.getAttributes());
|
keycloakOrg.getAttributes());
|
||||||
}
|
}
|
||||||
|
|
||||||
private OrganizationMemberResponse toOrganizationMemberResponse(
|
private OrganizationMemberResponse toOrganizationMemberResponse(
|
||||||
KeycloakUserRepresentation keycloakUser) {
|
UserRepresentation keycloakUser) {
|
||||||
if (keycloakUser == null) {
|
if (keycloakUser == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ spring:
|
|||||||
name: auth-service
|
name: auth-service
|
||||||
cloud:
|
cloud:
|
||||||
consul:
|
consul:
|
||||||
host: ${CONSUL_HOST:localhost}
|
host: ${CONSUL_HOST:127.0.0.1}
|
||||||
port: ${CONSUL_PORT:8500}
|
port: ${CONSUL_PORT:8500}
|
||||||
config:
|
config:
|
||||||
import-check:
|
import-check:
|
||||||
@@ -15,8 +15,8 @@ spring:
|
|||||||
|
|
||||||
# Keycloak Configuration (adjust values as needed)
|
# Keycloak Configuration (adjust values as needed)
|
||||||
keycloak:
|
keycloak:
|
||||||
keycloak-host: ${KEYCLOAK_AUTH_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://localhost:9100} # 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
|
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-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!)
|
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)
|
# Default Keycloak Client ID if no prefix matches (optional)
|
||||||
# defaultKeycloakClientId: "general-api-client"
|
# 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:
|
logging:
|
||||||
level:
|
level:
|
||||||
# Set log levels as needed.
|
# Set log levels as needed.
|
||||||
com.clevermicro.authservice: DEBUG
|
com.clevermicro.authservice: DEBUG
|
||||||
org.springframework.web.client.RestTemplate: 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 static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.cleverthis.authservice.controller.OrganizationController;
|
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.AddMemberRequest;
|
||||||
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
@@ -21,6 +20,7 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
@@ -52,8 +52,8 @@ public class OrganizationControllerTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
// Create the correct domain representation object
|
// Create the correct domain representation object
|
||||||
OrganizationDomainRepresentation domain =
|
final var domain = new OrganizationDomainRepresentation("test-org.com");
|
||||||
new OrganizationDomainRepresentation("test-org.com", true);
|
domain.setVerified(true);
|
||||||
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
|
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);
|
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
|
||||||
|
|||||||
+9
-2
@@ -3,10 +3,17 @@ package com.cleverthis.authservice.config;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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-host=https://keycloak.example.com",
|
||||||
"keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
|
"keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
|
||||||
"keycloak.realm=cm-test",
|
"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
|
assertNull(service.getConfig()); // fallback is null
|
||||||
// has json content
|
// has json content
|
||||||
when(value.getDecodedValue())
|
when(value.getDecodedValue())
|
||||||
.thenReturn(this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
|
.thenReturn(
|
||||||
|
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
|
||||||
service.refreshConsul();
|
service.refreshConsul();
|
||||||
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
|
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
|
||||||
}
|
}
|
||||||
@@ -76,45 +77,22 @@ class FallbackPermissionMapLookupServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testMatchClientIdByPath() {
|
void testMatchRuleByPath() {
|
||||||
final var defaultConfig = new PermissionMappingConfigProperties();
|
final var defaultConfig = new PermissionMappingConfigProperties();
|
||||||
defaultConfig.setRules(List.of(
|
final var ruleA = PermissionMappingConfigProperties.Rule.builder()
|
||||||
PermissionMappingConfigProperties.Rule.builder()
|
.keycloakClientId("service-A").pathPrefix("/a/").build();
|
||||||
.keycloakClientId("service-A").pathPrefix("/a/").build(),
|
final var ruleB = PermissionMappingConfigProperties.Rule.builder()
|
||||||
PermissionMappingConfigProperties.Rule.builder()
|
.keycloakClientId("service-B").pathPrefix("/b/").build();
|
||||||
.keycloakClientId("service-B").pathPrefix("/b/").build()
|
defaultConfig.setRules(List.of(ruleA, ruleB));
|
||||||
));
|
|
||||||
final var service = new FallbackPermissionMapLookupService(
|
final var service = new FallbackPermissionMapLookupService(
|
||||||
defaultConfig, this.consulClient, this.objectMapper);
|
defaultConfig, this.consulClient, this.objectMapper);
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"service-B",
|
ruleB,
|
||||||
service.matchClientIdByPath("/b/items/123").orElseThrow());
|
service.matchRuleByPath("/b/items/123").orElseThrow());
|
||||||
assertEquals(
|
assertEquals(
|
||||||
Optional.empty(),
|
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;
|
package com.cleverthis.authservice.service.impl;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
|
||||||
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
|
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
|
||||||
import com.cleverthis.authservice.dto.TokenResponse;
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
import com.cleverthis.authservice.dto.VerificationResponse;
|
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.AuthenticationException;
|
||||||
import com.cleverthis.authservice.exception.TokenVerificationException;
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import okhttp3.mockwebserver.MockResponse;
|
import okhttp3.mockwebserver.MockResponse;
|
||||||
import okhttp3.mockwebserver.MockWebServer;
|
import okhttp3.mockwebserver.MockWebServer;
|
||||||
import okhttp3.mockwebserver.RecordedRequest;
|
import okhttp3.mockwebserver.RecordedRequest;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
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.mockito.Mockito;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
class KeycloakIdentityManagerServiceTest {
|
class KeycloakIdentityManagerServiceTest {
|
||||||
private MockWebServer mockBackEnd;
|
private MockWebServer mockBackEnd;
|
||||||
private KeycloakAdminClient adminClient;
|
private KeycloakAdminReactiveClient adminClient;
|
||||||
private KeycloakIdentityManagerService service;
|
private KeycloakIdentityManagerService service;
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
@@ -40,7 +54,7 @@ class KeycloakIdentityManagerServiceTest {
|
|||||||
.clientId("test-client")
|
.clientId("test-client")
|
||||||
.clientSecret("client-password")
|
.clientSecret("client-password")
|
||||||
.build();
|
.build();
|
||||||
this.adminClient = Mockito.mock(KeycloakAdminClient.class);
|
this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class);
|
||||||
this.service = Mockito.spy(new KeycloakIdentityManagerService(
|
this.service = Mockito.spy(new KeycloakIdentityManagerService(
|
||||||
webClient, this.adminClient, configProperties));
|
webClient, this.adminClient, configProperties));
|
||||||
|
|
||||||
@@ -246,4 +260,375 @@ class KeycloakIdentityManagerServiceTest {
|
|||||||
.expectNext(false)
|
.expectNext(false)
|
||||||
.verifyComplete();
|
.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