Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cdde6ec922
|
|||
|
bed3de76dd
|
|||
|
465c7fd8ec
|
|||
|
6f0af690f2
|
|||
| 0fc59eef0e | |||
|
eaa14e7101
|
|||
| b764c6f12b | |||
| 2ff5930e86 | |||
| 4ff18c878b | |||
| 79dd8384e0 | |||
|
0e0c463a1e
|
|||
| ade56ec23d | |||
| 66767d9e1e | |||
| 33182d5a8e | |||
| 9889789fcc | |||
| ac85ff58af | |||
| 668bd05e5a | |||
| 0a16092b3d |
@@ -0,0 +1,9 @@
|
|||||||
|
# Note: You can use any Debian/Ubuntu based image you want.
|
||||||
|
FROM mcr.microsoft.com/devcontainers/base:bullseye
|
||||||
|
|
||||||
|
# [Optional] Uncomment this section to install additional OS packages.
|
||||||
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
&& apt-get -y install --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
curl \
|
||||||
|
jq
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
log_level = "INFO"
|
||||||
|
datacenter = "default_dc"
|
||||||
|
server = true
|
||||||
|
bootstrap_expect = 1
|
||||||
|
ui_config {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
# we need this line to listen on 0.0.0.0
|
||||||
|
client_addr = "0.0.0.0"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-outside-of-docker-compose
|
||||||
|
{
|
||||||
|
"name": "Docker from Docker Compose",
|
||||||
|
"dockerComposeFile": "docker-compose.yml",
|
||||||
|
"service": "app",
|
||||||
|
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||||
|
|
||||||
|
// Use this environment variable if you need to bind mount your local source code into a new container.
|
||||||
|
"remoteEnv": {
|
||||||
|
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
|
||||||
|
"WORKSPACE_FOLDER": "/workspaces/${localWorkspaceFolderBasename}"
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||||
|
"version": "latest",
|
||||||
|
"enableNonRootDocker": "true",
|
||||||
|
"moby": "false"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/java:1": {
|
||||||
|
"version": "21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
"forwardPorts": [
|
||||||
|
8099,
|
||||||
|
"rabbitmq:5672",
|
||||||
|
"rabbitmq:15672",
|
||||||
|
"consul:8500",
|
||||||
|
"keycloak:8000",
|
||||||
|
],
|
||||||
|
"postCreateCommand": "/bin/bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/setup.sh",
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Forwards the local Docker socket to the container.
|
||||||
|
- /var/run/docker.sock:/var/run/docker-host.sock
|
||||||
|
# Update this to wherever you want VS Code to mount the folder of your project
|
||||||
|
- ../..:/workspaces:cached
|
||||||
|
|
||||||
|
# Overrides default command so things don't shut down after the process ends.
|
||||||
|
entrypoint: /usr/local/share/docker-init.sh
|
||||||
|
command: sleep infinity
|
||||||
|
|
||||||
|
# Uncomment the next four lines if you will use a ptrace-based debuggers like C++, Go, and Rust.
|
||||||
|
# cap_add:
|
||||||
|
# - SYS_PTRACE
|
||||||
|
# security_opt:
|
||||||
|
# - seccomp:unconfined
|
||||||
|
|
||||||
|
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||||
|
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||||
|
depends_on:
|
||||||
|
- rabbitmq
|
||||||
|
- consul
|
||||||
|
- keycloak
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:4-management
|
||||||
|
hostname: "my-rabbit"
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: springuser
|
||||||
|
RABBITMQ_DEFAULT_PASS: TheCleverWho
|
||||||
|
volumes:
|
||||||
|
- "rabbitmq-data:/var/lib/rabbitmq"
|
||||||
|
|
||||||
|
consul:
|
||||||
|
image: hashicorp/consul:1.20
|
||||||
|
command:
|
||||||
|
- "agent"
|
||||||
|
- "-data-dir=/consul/data"
|
||||||
|
- "-config-file=/config/config.hcl"
|
||||||
|
environment:
|
||||||
|
- CONSUL_BIND_INTERFACE=eth0
|
||||||
|
volumes:
|
||||||
|
- "consul-data:/consul/data"
|
||||||
|
- "./consul-main-config.hcl:/config/config.hcl"
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: "quay.io/keycloak/keycloak:26.2.5"
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- "keycloak.dev.localhost"
|
||||||
|
environment:
|
||||||
|
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||||
|
- KC_HOSTNAME=http://keycloak.dev.localhost:8000
|
||||||
|
- KC_HOSTNAME_ADMIN=http://keycloak.dev.localhost:8000
|
||||||
|
- KC_HTTP_PORT=8000
|
||||||
|
command:
|
||||||
|
- start
|
||||||
|
# enable http, let reverse proxy handles the TLS
|
||||||
|
- --http-enabled=true
|
||||||
|
# take X-Forwarded-* headers from reverse proxy
|
||||||
|
- --proxy-headers=xforwarded
|
||||||
|
# hide node name in cookie for sticky session, we're using reverse proxy for that
|
||||||
|
# ref: https://www.keycloak.org/server/reverseproxy#_enable_sticky_sessions
|
||||||
|
- --spi-sticky-session-encoder-infinispan-should-attach-route=false
|
||||||
|
# read hostname from reverse proxy headers
|
||||||
|
- --hostname-strict=false
|
||||||
|
# Limit the request queue to prevent OOM
|
||||||
|
- --http-max-queued-requests=200
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rabbitmq-data:
|
||||||
|
consul-data:
|
||||||
Submodule
+1
Submodule .devcontainer/identity-management added at 12d8e11f3b
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd ${WORKSPACE_FOLDER}/.devcontainer/identity-management/
|
||||||
|
|
||||||
|
./tools/setup-cli/setup-admin-cli.sh
|
||||||
|
sleep 10 # wait keycloak
|
||||||
|
./tools/login-admin/login-admin.sh -s=http://keycloak.dev.localhost:8000 -u=admin -p=admin
|
||||||
|
./tools/create-realm/create-realm.sh -n=clevermicro-dev
|
||||||
|
./tools/create-client/create-client.sh -r=clevermicro-dev -n=document-service
|
||||||
|
./tools/dev-realm/set-up.sh -r=clevermicro-dev
|
||||||
|
./tools/create-user/create-user.sh -r=clevermicro-dev -u=test -p=password -f=Jhon -l=Doe -e=jhon.doe@example.com
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: "Unit test coverage"
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
env:
|
||||||
|
MIN_COVERAGE_PERCENTAGE: 85
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
TZ: UTC
|
||||||
|
DOCKER_HOST: "tcp://docker:2375"
|
||||||
|
DOCKER_TLS_VERIFY: ""
|
||||||
|
# Test containers suggest this will yield better fs performance
|
||||||
|
DOCKER_DRIVER: overlay2
|
||||||
|
# ryuk need privileged permission, disable it in CI
|
||||||
|
TESTCONTAINERS_RYUK_DISABLED: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# gradle test for modules
|
||||||
|
gradle-test:
|
||||||
|
runs-on: general
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
services:
|
||||||
|
docker:
|
||||||
|
image: docker:dind
|
||||||
|
cmd:
|
||||||
|
- "dockerd"
|
||||||
|
- "-H"
|
||||||
|
- "tcp://0.0.0.0:2375"
|
||||||
|
- "--tls=false"
|
||||||
|
steps:
|
||||||
|
# need to setup node and git
|
||||||
|
- run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs git curl
|
||||||
|
# check out code
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: https://github.com/actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: "21"
|
||||||
|
# ensure executable
|
||||||
|
- run: chmod +x ./gradlew
|
||||||
|
# run gradle test
|
||||||
|
- run: ./gradlew check --no-daemon
|
||||||
|
env:
|
||||||
|
MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
# process jacoco report
|
||||||
|
- name: Collect coverage report
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
INPUT=$(grep -o '<counter type="LINE" [^>]*>' build/reports/jacoco/test/jacocoTestReport.xml | tail -1)
|
||||||
|
MISSED=$(echo "$INPUT" | sed -n 's/.*missed="\([0-9]*\)".*/\1/p')
|
||||||
|
COVERED=$(echo "$INPUT" | sed -n 's/.*covered="\([0-9]*\)".*/\1/p')
|
||||||
|
echo "MISSED: $MISSED"
|
||||||
|
echo "COVERED: $COVERED"
|
||||||
|
COVERAGE_PERCENTAGE=$(( 100 * $COVERED / ($COVERED + $MISSED) ))
|
||||||
|
echo "Code coverage: ${COVERAGE_PERCENTAGE}%"
|
||||||
|
echo "Minimal coverage: ${MIN_COVERAGE_PERCENTAGE}%"
|
||||||
|
echo "percentage=${COVERAGE_PERCENTAGE}" >> $GITHUB_OUTPUT
|
||||||
|
- name: Post coverage to RP
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
curl --fail \
|
||||||
|
-X POST '${{github.api_url}}/repos/${{github.repository}}/issues/${{github.event.number}}/comments' \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${{github.token}}" \
|
||||||
|
-d '{"body":"Coverage is ${{steps.coverage.outputs.percentage}}%"}'
|
||||||
|
- name: Check coverage rate
|
||||||
|
run: |
|
||||||
|
if [ ${{steps.coverage.outputs.percentage}} -lt $MIN_COVERAGE_PERCENTAGE ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: "CI for publishing docker image"
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**" # build on all branches since the tag is decided by branch name
|
||||||
|
workflow_dispatch:
|
||||||
|
# allow manual trigger
|
||||||
|
env:
|
||||||
|
REGISTRY_URL: "git.cleverthis.com"
|
||||||
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
TZ: UTC
|
||||||
|
DOCKER_HOST: "tcp://docker:2375"
|
||||||
|
DOCKER_TLS_VERIFY: ""
|
||||||
|
jobs:
|
||||||
|
build-and-publish:
|
||||||
|
runs-on: general
|
||||||
|
services:
|
||||||
|
docker:
|
||||||
|
image: docker:dind
|
||||||
|
cmd:
|
||||||
|
- "dockerd"
|
||||||
|
- "-H"
|
||||||
|
- "tcp://0.0.0.0:2375"
|
||||||
|
- "--tls=false"
|
||||||
|
container:
|
||||||
|
image: ubuntu:24.04
|
||||||
|
steps:
|
||||||
|
# need to setup node and git
|
||||||
|
- run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs git
|
||||||
|
# check out code
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# provide slug version of the envs
|
||||||
|
- name: Slugify ref name
|
||||||
|
uses: https://github.com/rlespinasse/slugify-value@v1.4.0
|
||||||
|
with:
|
||||||
|
key: GITHUB_REF_NAME
|
||||||
|
prefix: ""
|
||||||
|
slug-maxlength: 80
|
||||||
|
# shorten the sha
|
||||||
|
- name: Shorten SHA
|
||||||
|
uses: https://github.com/rlespinasse/shortify-git-revision@v1.6.0
|
||||||
|
with:
|
||||||
|
name: GITHUB_SHA
|
||||||
|
short-on-error: true
|
||||||
|
length: 7
|
||||||
|
prefix: ""
|
||||||
|
# install jdk
|
||||||
|
- uses: https://github.com/actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: "21"
|
||||||
|
# ensure executable
|
||||||
|
- run: chmod +x ./gradlew
|
||||||
|
# run gradle test and build
|
||||||
|
# disabled since gradle doesn't require maven token anymore
|
||||||
|
# - run: |
|
||||||
|
# ./gradlew check
|
||||||
|
# ./gradlew bootJar
|
||||||
|
# env:
|
||||||
|
# MAVEN_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
# setup docker
|
||||||
|
- name: set up docker cli
|
||||||
|
run: |
|
||||||
|
apt-get install -y ca-certificates curl dnsutils
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||||
|
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||||
|
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
apt-get update
|
||||||
|
apt-get install docker-ce-cli docker-buildx-plugin docker-compose-plugin
|
||||||
|
# Login to register
|
||||||
|
- uses: https://github.com/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
# file: ci.Dockerfile
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_REF_NAME_SLUG }}
|
||||||
|
${{ env.REGISTRY_URL }}/${{ env.GITHUB_REPOSITORY }}:${{ env.GITHUB_SHA_SHORT }}
|
||||||
+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/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule ".devcontainer/identity-management"]
|
||||||
|
path = .devcontainer/identity-management
|
||||||
|
url = ../identity-management.git
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM gradle:jdk21 AS build
|
||||||
|
|
||||||
|
WORKDIR /code
|
||||||
|
COPY . /code
|
||||||
|
RUN gradle bootJar --no-daemon
|
||||||
|
|
||||||
|
FROM azul/zulu-openjdk:21-latest
|
||||||
|
EXPOSE 8099
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /code/build/libs/*.jar /app/app.jar
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
plugins {
|
||||||
|
java
|
||||||
|
id("org.springframework.boot") version "3.5.3"
|
||||||
|
id("io.spring.dependency-management") version "1.1.7"
|
||||||
|
jacoco
|
||||||
|
checkstyle
|
||||||
|
idea
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.cleverthis.clevermicro"
|
||||||
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
compileOnly {
|
||||||
|
extendsFrom(configurations.annotationProcessor.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations.all {
|
||||||
|
// disable the cache, so it will fetch the latest snapshot for client api
|
||||||
|
resolutionStrategy.cacheChangingModulesFor(30, TimeUnit.SECONDS)
|
||||||
|
resolutionStrategy.cacheDynamicVersionsFor(30, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = "cleverthis-forgejo-maven"
|
||||||
|
url = uri("https://git.cleverthis.com/api/packages/clevermicro/maven")
|
||||||
|
// disable auth since artifacts are publicly available
|
||||||
|
// if (System.getenv("MAVEN_TOKEN").isNullOrBlank()) {
|
||||||
|
// credentials(HttpHeaderCredentials::class) {
|
||||||
|
// name = "Authorization"
|
||||||
|
// val token =
|
||||||
|
// findProperty("cleverThisForgejoToken") as String? ?: error("Token not found")
|
||||||
|
// value = "token $token"
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// credentials(HttpHeaderCredentials::class) {
|
||||||
|
// name = "Authorization"
|
||||||
|
// val token = System.getenv("MAVEN_TOKEN")
|
||||||
|
// value = "token $token"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// authentication {
|
||||||
|
// create("header", HttpHeaderAuthentication::class)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extra["springCloudVersion"] = "2025.0.0"
|
||||||
|
|
||||||
|
dependencyManagement {
|
||||||
|
imports {
|
||||||
|
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
|
||||||
|
val mockitoAgent = configurations.create("mockitoAgent")
|
||||||
|
dependencies {
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-webflux")
|
||||||
|
implementation("org.springframework.cloud:spring-cloud-starter-consul-config")
|
||||||
|
compileOnly("org.projectlombok:lombok")
|
||||||
|
annotationProcessor("org.projectlombok:lombok")
|
||||||
|
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||||
|
|
||||||
|
// clevermicro
|
||||||
|
implementation("com.cleverthis.clevermicro:core:0.2.0-SNAPSHOT")
|
||||||
|
|
||||||
|
// keycloak admin client
|
||||||
|
implementation("org.keycloak:keycloak-admin-client:26.0.6")
|
||||||
|
|
||||||
|
// opentelemetry
|
||||||
|
implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.11.0"))
|
||||||
|
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
|
||||||
|
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
testImplementation("io.projectreactor:reactor-test")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
|
mockitoAgent("org.mockito:mockito-core") { isTransitive = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
jvmArgs("-javaagent:${mockitoAgent.asPath}")
|
||||||
|
useJUnitPlatform()
|
||||||
|
// generate jacoco test report
|
||||||
|
finalizedBy("jacocoTestReport")
|
||||||
|
// show full output streams so we can debug for pipelines
|
||||||
|
testLogging {
|
||||||
|
showStandardStreams = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.jacocoTestReport {
|
||||||
|
dependsOn(tasks.test)
|
||||||
|
reports {
|
||||||
|
xml.required.set(true)
|
||||||
|
html.required.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkstyle {
|
||||||
|
toolVersion = "10.23.1"
|
||||||
|
configFile = file("${project.rootDir}/checkstyle.xml")
|
||||||
|
isShowViolations = true
|
||||||
|
isIgnoreFailures = false
|
||||||
|
maxWarnings = 0
|
||||||
|
maxErrors = 0
|
||||||
|
|
||||||
|
sourceSets = setOf(project.sourceSets["main"])
|
||||||
|
}
|
||||||
|
|
||||||
|
idea { // tell IDEA to always download source code and javadoc
|
||||||
|
module {
|
||||||
|
isDownloadJavadoc = true
|
||||||
|
isDownloadSources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!DOCTYPE module PUBLIC
|
||||||
|
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||||
|
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
This used to be a Checkstyle config by Max Vetrenko, Ruslan Diachenko,
|
||||||
|
and Roman Ivanov.
|
||||||
|
|
||||||
|
It has since been modified to include changes specific to CleverThis.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<module name="Checker">
|
||||||
|
<module name="SuppressWarningsFilter"/>
|
||||||
|
|
||||||
|
<property name="charset" value="UTF-8"/>
|
||||||
|
|
||||||
|
<property name="severity" value="warning"/>
|
||||||
|
|
||||||
|
<property name="fileExtensions" value="java, properties, xml"/>
|
||||||
|
<!-- Excludes all 'module-info.java' files -->
|
||||||
|
<!-- See https://checkstyle.org/filefilters/index.html -->
|
||||||
|
<module name="BeforeExecutionExclusionFileFilter">
|
||||||
|
<property name="fileNamePattern" value="module\-info\.java$"/>
|
||||||
|
</module>
|
||||||
|
<!-- https://checkstyle.org/filters/suppressionfilter.html -->
|
||||||
|
<module name="SuppressionFilter">
|
||||||
|
<property name="file" value="${org.checkstyle.google.suppressionfilter.config}"
|
||||||
|
default="checkstyle-suppressions.xml" />
|
||||||
|
<property name="optional" value="true"/>
|
||||||
|
</module>
|
||||||
|
|
||||||
|
<!-- Checks for whitespace -->
|
||||||
|
<!-- See http://checkstyle.org/checks/whitespace/index.html -->
|
||||||
|
<module name="FileTabCharacter">
|
||||||
|
<property name="eachLine" value="true"/>
|
||||||
|
</module>
|
||||||
|
|
||||||
|
<module name="LineLength">
|
||||||
|
<property name="fileExtensions" value="java"/>
|
||||||
|
<property name="max" value="100"/>
|
||||||
|
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||||
|
</module>
|
||||||
|
|
||||||
|
<module name="TreeWalker">
|
||||||
|
<module name="OuterTypeFilename"/>
|
||||||
|
<module name="IllegalTokenText">
|
||||||
|
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||||
|
<property name="format"
|
||||||
|
value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||||
|
<property name="message"
|
||||||
|
value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||||
|
</module>
|
||||||
|
<module name="AvoidEscapedUnicodeCharacters">
|
||||||
|
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||||
|
<property name="allowByTailComment" value="true"/>
|
||||||
|
<property name="allowNonPrintableEscapes" value="true"/>
|
||||||
|
</module>
|
||||||
|
<module name="AvoidStarImport"/>
|
||||||
|
<module name="OneTopLevelClass"/>
|
||||||
|
<module name="NoLineWrap">
|
||||||
|
<property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT"/>
|
||||||
|
</module>
|
||||||
|
<module name="EmptyBlock">
|
||||||
|
<property name="option" value="TEXT"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||||
|
</module>
|
||||||
|
<module name="NeedBraces">
|
||||||
|
<property name="tokens"
|
||||||
|
value="LITERAL_DO, LITERAL_ELSE, LITERAL_FOR, LITERAL_IF, LITERAL_WHILE"/>
|
||||||
|
</module>
|
||||||
|
<module name="LeftCurly">
|
||||||
|
<property name="tokens"
|
||||||
|
value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF, ENUM_DEF,
|
||||||
|
INTERFACE_DEF, LAMBDA, LITERAL_CATCH, LITERAL_DEFAULT,
|
||||||
|
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF,
|
||||||
|
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, METHOD_DEF,
|
||||||
|
OBJBLOCK, STATIC_INIT, RECORD_DEF, COMPACT_CTOR_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="RightCurly">
|
||||||
|
<property name="id" value="RightCurlySame"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE,
|
||||||
|
LITERAL_DO"/>
|
||||||
|
</module>
|
||||||
|
<module name="RightCurly">
|
||||||
|
<property name="id" value="RightCurlyAlone"/>
|
||||||
|
<property name="option" value="alone"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT,
|
||||||
|
INSTANCE_INIT, ANNOTATION_DEF, ENUM_DEF, INTERFACE_DEF, RECORD_DEF,
|
||||||
|
COMPACT_CTOR_DEF, LITERAL_SWITCH"/>
|
||||||
|
</module>
|
||||||
|
<module name="SuppressionXpathSingleFilter">
|
||||||
|
<!-- suppresion is required till https://github.com/checkstyle/checkstyle/issues/7541 -->
|
||||||
|
<property name="id" value="RightCurlyAlone"/>
|
||||||
|
<property name="query" value="//RCURLY[parent::SLIST[count(./*)=1]
|
||||||
|
or preceding-sibling::*[last()][self::LCURLY]]"/>
|
||||||
|
</module>
|
||||||
|
<module name="WhitespaceAfter">
|
||||||
|
<property name="tokens"
|
||||||
|
value="COMMA, SEMI, TYPECAST, LITERAL_IF, LITERAL_ELSE, LITERAL_RETURN,
|
||||||
|
LITERAL_WHILE, LITERAL_DO, LITERAL_FOR, LITERAL_FINALLY, DO_WHILE, ELLIPSIS,
|
||||||
|
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_CATCH, LAMBDA,
|
||||||
|
LITERAL_YIELD"/>
|
||||||
|
</module>
|
||||||
|
<module name="WhitespaceAround">
|
||||||
|
<property name="allowEmptyConstructors" value="true"/>
|
||||||
|
<property name="allowEmptyLambdas" value="true"/>
|
||||||
|
<property name="allowEmptyMethods" value="true"/>
|
||||||
|
<property name="allowEmptyTypes" value="true"/>
|
||||||
|
<property name="allowEmptyLoops" value="true"/>
|
||||||
|
<property name="ignoreEnhancedForColon" value="false"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
|
||||||
|
BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
|
||||||
|
LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
|
||||||
|
LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
|
||||||
|
LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
|
||||||
|
NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
|
||||||
|
SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT, TYPE_EXTENSION_AND"/>
|
||||||
|
<message key="ws.notFollowed"
|
||||||
|
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks
|
||||||
|
may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||||
|
<message key="ws.notPreceded"
|
||||||
|
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||||
|
</module>
|
||||||
|
<module name="OneStatementPerLine"/>
|
||||||
|
<module name="MultipleVariableDeclarations"/>
|
||||||
|
<module name="ArrayTypeStyle"/>
|
||||||
|
<module name="MissingSwitchDefault"/>
|
||||||
|
<module name="FallThrough"/>
|
||||||
|
<module name="UpperEll"/>
|
||||||
|
<module name="ModifierOrder"/>
|
||||||
|
<module name="EmptyLineSeparator">
|
||||||
|
<property name="tokens"
|
||||||
|
value="PACKAGE_DEF, IMPORT, STATIC_IMPORT, CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||||
|
STATIC_INIT, INSTANCE_INIT, METHOD_DEF, CTOR_DEF, VARIABLE_DEF, RECORD_DEF,
|
||||||
|
COMPACT_CTOR_DEF"/>
|
||||||
|
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||||
|
</module>
|
||||||
|
<module name="SeparatorWrap">
|
||||||
|
<property name="id" value="SeparatorWrapDot"/>
|
||||||
|
<property name="tokens" value="DOT"/>
|
||||||
|
<property name="option" value="nl"/>
|
||||||
|
</module>
|
||||||
|
<module name="SeparatorWrap">
|
||||||
|
<property name="id" value="SeparatorWrapComma"/>
|
||||||
|
<property name="tokens" value="COMMA"/>
|
||||||
|
<property name="option" value="EOL"/>
|
||||||
|
</module>
|
||||||
|
<module name="SeparatorWrap">
|
||||||
|
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||||
|
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||||
|
<property name="tokens" value="ELLIPSIS"/>
|
||||||
|
<property name="option" value="EOL"/>
|
||||||
|
</module>
|
||||||
|
<module name="SeparatorWrap">
|
||||||
|
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||||
|
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||||
|
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||||
|
<property name="option" value="EOL"/>
|
||||||
|
</module>
|
||||||
|
<module name="SeparatorWrap">
|
||||||
|
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||||
|
<property name="tokens" value="METHOD_REF"/>
|
||||||
|
<property name="option" value="nl"/>
|
||||||
|
</module>
|
||||||
|
<module name="PackageName">
|
||||||
|
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="TypeName">
|
||||||
|
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||||
|
ANNOTATION_DEF, RECORD_DEF"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="MemberName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9][a-zA-Z0-9]*$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="ParameterName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="LambdaParameterName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Lambda parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="CatchParameterName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="LocalVariableName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="PatternVariableName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Pattern variable name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="ClassTypeParameterName">
|
||||||
|
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="RecordComponentName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]*([A-Z][a-z0-9]{1,})*[A-Z]{0,1}$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Record component name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="RecordTypeParameterName">
|
||||||
|
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Record type name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="MethodTypeParameterName">
|
||||||
|
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="InterfaceTypeParameterName">
|
||||||
|
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="NoFinalizer"/>
|
||||||
|
<module name="GenericWhitespace">
|
||||||
|
<message key="ws.followed"
|
||||||
|
value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||||
|
<message key="ws.preceded"
|
||||||
|
value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||||
|
<message key="ws.illegalFollow"
|
||||||
|
value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||||
|
<message key="ws.notPreceded"
|
||||||
|
value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||||
|
</module>
|
||||||
|
<module name="Indentation">
|
||||||
|
<property name="basicOffset" value="4"/>
|
||||||
|
<property name="braceAdjustment" value="4"/>
|
||||||
|
<property name="caseIndent" value="4"/>
|
||||||
|
<property name="throwsIndent" value="6"/>
|
||||||
|
<property name="lineWrappingIndentation" value="6"/>
|
||||||
|
<property name="arrayInitIndent" value="6"/>
|
||||||
|
</module>
|
||||||
|
<module name="AbbreviationAsWordInName">
|
||||||
|
<property name="ignoreFinal" value="false"/>
|
||||||
|
<property name="allowedAbbreviationLength" value="0"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, ANNOTATION_FIELD_DEF,
|
||||||
|
PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
|
||||||
|
RECORD_COMPONENT_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="NoWhitespaceBeforeCaseDefaultColon"/>
|
||||||
|
<module name="OverloadMethodsDeclarationOrder"/>
|
||||||
|
<module name="VariableDeclarationUsageDistance"/>
|
||||||
|
<module name="CustomImportOrder">
|
||||||
|
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||||
|
<property name="separateLineBetweenGroups" value="true"/>
|
||||||
|
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||||
|
<property name="tokens" value="IMPORT, STATIC_IMPORT, PACKAGE_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="MethodParamPad">
|
||||||
|
<property name="tokens"
|
||||||
|
value="CTOR_DEF, LITERAL_NEW, METHOD_CALL, METHOD_DEF,
|
||||||
|
SUPER_CTOR_CALL, ENUM_CONSTANT_DEF, RECORD_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="NoWhitespaceBefore">
|
||||||
|
<property name="tokens"
|
||||||
|
value="COMMA, SEMI, POST_INC, POST_DEC, DOT,
|
||||||
|
LABELED_STAT, METHOD_REF"/>
|
||||||
|
<property name="allowLineBreaks" value="true"/>
|
||||||
|
</module>
|
||||||
|
<module name="ParenPad">
|
||||||
|
<property name="tokens"
|
||||||
|
value="ANNOTATION, ANNOTATION_FIELD_DEF, CTOR_CALL, CTOR_DEF, DOT, ENUM_CONSTANT_DEF,
|
||||||
|
EXPR, LITERAL_CATCH, LITERAL_DO, LITERAL_FOR, LITERAL_IF, LITERAL_NEW,
|
||||||
|
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_WHILE, METHOD_CALL,
|
||||||
|
METHOD_DEF, QUESTION, RESOURCE_SPECIFICATION, SUPER_CTOR_CALL, LAMBDA,
|
||||||
|
RECORD_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="OperatorWrap">
|
||||||
|
<property name="option" value="NL"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR,
|
||||||
|
LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF,
|
||||||
|
TYPE_EXTENSION_AND "/>
|
||||||
|
</module>
|
||||||
|
<module name="AnnotationLocation">
|
||||||
|
<property name="id" value="AnnotationLocationMostCases"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF,
|
||||||
|
RECORD_DEF, COMPACT_CTOR_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="AnnotationLocation">
|
||||||
|
<property name="id" value="AnnotationLocationVariables"/>
|
||||||
|
<property name="tokens" value="VARIABLE_DEF"/>
|
||||||
|
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||||
|
</module>
|
||||||
|
<module name="NonEmptyAtclauseDescription"/>
|
||||||
|
<module name="InvalidJavadocPosition"/>
|
||||||
|
<module name="JavadocTagContinuationIndentation"/>
|
||||||
|
<module name="SummaryJavadoc">
|
||||||
|
<property name="forbiddenSummaryFragments"
|
||||||
|
value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )"/>
|
||||||
|
</module>
|
||||||
|
<module name="RequireEmptyLineBeforeBlockTagGroup"/>
|
||||||
|
<module name="AtclauseOrder">
|
||||||
|
<property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
|
||||||
|
<property name="target"
|
||||||
|
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF, VARIABLE_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="JavadocMethod">
|
||||||
|
<property name="accessModifiers" value="public"/>
|
||||||
|
<property name="allowMissingParamTags" value="true"/>
|
||||||
|
<property name="allowMissingReturnTag" value="true"/>
|
||||||
|
<property name="allowedAnnotations" value="Override, Test"/>
|
||||||
|
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF, COMPACT_CTOR_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="MissingJavadocMethod">
|
||||||
|
<property name="scope" value="public"/>
|
||||||
|
<property name="minLineCount" value="2"/>
|
||||||
|
<property name="allowedAnnotations" value="Override, Test"/>
|
||||||
|
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF,
|
||||||
|
COMPACT_CTOR_DEF"/>
|
||||||
|
</module>
|
||||||
|
<module name="MissingJavadocType">
|
||||||
|
<property name="scope" value="protected"/>
|
||||||
|
<property name="tokens"
|
||||||
|
value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||||
|
RECORD_DEF, ANNOTATION_DEF"/>
|
||||||
|
<property name="excludeScope" value="nothing"/>
|
||||||
|
</module>
|
||||||
|
<module name="MethodName">
|
||||||
|
<property name="format" value="^[a-z][a-z0-9]\w*$"/>
|
||||||
|
<message key="name.invalidPattern"
|
||||||
|
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||||
|
</module>
|
||||||
|
<module name="SingleLineJavadoc"/>
|
||||||
|
<module name="EmptyCatchBlock">
|
||||||
|
<property name="exceptionVariableName" value="expected"/>
|
||||||
|
</module>
|
||||||
|
<module name="CommentsIndentation">
|
||||||
|
<property name="tokens" value="SINGLE_LINE_COMMENT, BLOCK_COMMENT_BEGIN"/>
|
||||||
|
</module>
|
||||||
|
<!-- https://checkstyle.org/filters/suppressionxpathfilter.html -->
|
||||||
|
<module name="SuppressionXpathFilter">
|
||||||
|
<property name="file" value="${org.checkstyle.google.suppressionxpathfilter.config}"
|
||||||
|
default="checkstyle-xpath-suppressions.xml" />
|
||||||
|
<property name="optional" value="true"/>
|
||||||
|
</module>
|
||||||
|
<module name="SuppressWarningsHolder" />
|
||||||
|
<module name="SuppressionCommentFilter">
|
||||||
|
<property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)" />
|
||||||
|
<property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)" />
|
||||||
|
<property name="checkFormat" value="$1" />
|
||||||
|
</module>
|
||||||
|
<module name="SuppressWithNearbyCommentFilter">
|
||||||
|
<property name="commentFormat" value="CHECKSTYLE.SUPPRESS\: ([\w\|]+)"/>
|
||||||
|
<!-- $1 refers to the first match group in the regex defined in commentFormat -->
|
||||||
|
<property name="checkFormat" value="$1"/>
|
||||||
|
<!-- The check is suppressed in the next line of code after the comment -->
|
||||||
|
<property name="influenceFormat" value="1"/>
|
||||||
|
</module>
|
||||||
|
<module name="RequireThis">
|
||||||
|
<property name="checkMethods" value="false"/>
|
||||||
|
<property name="validateOnlyOverlapping" value="false"/>
|
||||||
|
</module>
|
||||||
|
</module>
|
||||||
|
</module>
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "auth-service"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-Service starter.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class AuthServiceApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AuthServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.service.impl.KeycloakAdminReactiveClient;
|
||||||
|
import org.keycloak.admin.client.Keycloak;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config for keycloak clients:
|
||||||
|
* {@link com.cleverthis.authservice.service.impl.KeycloakIdentityManagerService}
|
||||||
|
* and {@link KeycloakAdminReactiveClient}.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(KeycloakClientConfigProperties.class)
|
||||||
|
public class KeycloakClientConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config model for {@link KeycloakClientConfig}.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "keycloak")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Builder
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class KeycloakClientConfigProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host name of your Keycloak instance.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak server URL cannot be blank")
|
||||||
|
private String keycloakHost = "http://keycloak.dev.localhost";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin host name of your Keycloak instance.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak server URL cannot be blank")
|
||||||
|
private String keycloakAdminHost = "http://keycloak-admin.dev.localhost";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The realm name.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak realm cannot be blank")
|
||||||
|
private String realm = "clevermicro-dev";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client id for calling OIDC endpoints.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak client ID cannot be blank")
|
||||||
|
private String clientId = "auth-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client secret.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak client secret cannot be blank")
|
||||||
|
private String clientSecret = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin account for accessing the admin endpoints.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak admin account username cannot be blank")
|
||||||
|
private String adminAccountUsername = "auth-service-account";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin account password.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
@NotBlank(message = "Keycloak admin account password cannot be blank")
|
||||||
|
private String adminAccountPassword = "";
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for mapping request paths to Keycloak Client IDs.
|
||||||
|
* Loaded from application.yml under 'auth-service.permission-mapping'.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "auth-service.permission-mapping")
|
||||||
|
@Data
|
||||||
|
@Validated
|
||||||
|
public class PermissionMappingConfigProperties {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
private List<Rule> rules = new ArrayList<>();
|
||||||
|
private String defaultKeycloakClientId; // Optional default
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mapping request paths to Keycloak Client IDs.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Rule {
|
||||||
|
@NotBlank(message = "Path prefix cannot be blank in permission mapping rule")
|
||||||
|
private String pathPrefix;
|
||||||
|
@NotBlank(message = "Keycloak Client ID cannot be blank in permission mapping rule")
|
||||||
|
private String keycloakClientId;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private List<PassThrough> passthroughs = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the pass through rules.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class PassThrough {
|
||||||
|
@NotBlank(message = "Type must not be blank")
|
||||||
|
private Type type;
|
||||||
|
@NotBlank(message = "The value of the rule must not be blank")
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported pass-through types.
|
||||||
|
*/
|
||||||
|
public enum Type {
|
||||||
|
/**
|
||||||
|
* Use regex defined in value to match the path.
|
||||||
|
* Example value: {@code ^/path/(a-zA-Z)+}.
|
||||||
|
*/
|
||||||
|
PATH_REGEX,
|
||||||
|
/**
|
||||||
|
* Use the prefix defined in value to match the path.
|
||||||
|
* Example value: {@code /path/}.
|
||||||
|
*/
|
||||||
|
PATH_PREFIX
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import io.netty.channel.ChannelOption;
|
||||||
|
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||||
|
import io.netty.handler.timeout.WriteTimeoutHandler;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration class for creating and configuring the WebClient bean used for
|
||||||
|
* making HTTP requests
|
||||||
|
* to Keycloak.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class WebClientConfig {
|
||||||
|
|
||||||
|
// Timeout values in milliseconds
|
||||||
|
private static final int CONNECT_TIMEOUT = 5000; // 5 seconds
|
||||||
|
private static final int READ_TIMEOUT = 10000; // 10 seconds
|
||||||
|
private static final int WRITE_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide a {@link WebClient} for interacting with keycloak.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public WebClient keycloakWebClient() {
|
||||||
|
// Configure HttpClient with timeouts
|
||||||
|
HttpClient httpClient = HttpClient.create()
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT)
|
||||||
|
.responseTimeout(Duration.ofMillis(READ_TIMEOUT)) // Max time to wait for response
|
||||||
|
.doOnConnected(conn -> conn.addHandlerLast(
|
||||||
|
new ReadTimeoutHandler(READ_TIMEOUT, TimeUnit.MILLISECONDS))
|
||||||
|
.addHandlerLast(
|
||||||
|
new WriteTimeoutHandler(WRITE_TIMEOUT, TimeUnit.MILLISECONDS)));
|
||||||
|
|
||||||
|
// Build WebClient with the configured HttpClient
|
||||||
|
return WebClient.builder()
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package com.cleverthis.authservice.controller;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
|
import com.cleverthis.authservice.dto.LoginRequest;
|
||||||
|
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||||
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||||
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import com.cleverthis.authservice.service.PermissionMapLookupService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMethod;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller for handling authentication related requests. Delegates the actual identity
|
||||||
|
* provider interaction to the IdentityManagerService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/")
|
||||||
|
@Slf4j
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
// --- Constants for Header Names ---
|
||||||
|
private static final String HEADER_USER_ID = "X-User-Id";
|
||||||
|
private static final String HEADER_USER_NAME = "X-User-Name";
|
||||||
|
private static final String HEADER_USER_EMAIL = "X-User-Email";
|
||||||
|
private static final String HEADER_USER_GROUPS = "X-User-Groups";
|
||||||
|
// Add more headers as needed (e.g., X-User-Roles)
|
||||||
|
|
||||||
|
// --- Traefik Forwarded Headers ---
|
||||||
|
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
|
||||||
|
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri"; // Full original URI
|
||||||
|
// private static final String HEADER_X_FORWARDED_HOST = "X-Forwarded-Host";
|
||||||
|
// private static final String HEADER_X_FORWARDED_PROTO = "X-Forwarded-Proto";
|
||||||
|
|
||||||
|
private final IdentityManagerService identityManagerService;
|
||||||
|
private final PermissionMapLookupService permissionMapLookupService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AuthController(
|
||||||
|
IdentityManagerService identityManagerService,
|
||||||
|
PermissionMapLookupService permissionMapLookupService
|
||||||
|
) {
|
||||||
|
this.identityManagerService = identityManagerService;
|
||||||
|
this.permissionMapLookupService = permissionMapLookupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles user login requests using username and password.
|
||||||
|
*
|
||||||
|
* @param loginRequest DTO containing username and password.
|
||||||
|
* @return ResponseEntity containing the TokenResponse on success, or an error status handled
|
||||||
|
* globally.
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Mono<ResponseEntity<TokenResponse>> login(
|
||||||
|
@Valid @RequestBody LoginRequest loginRequest) {
|
||||||
|
log.info("Received login request for user: {}", loginRequest.getUsername());
|
||||||
|
return this.identityManagerService
|
||||||
|
.login(loginRequest.getUsername(), loginRequest.getPassword())
|
||||||
|
.map(ResponseEntity::ok) // Map successful response to ResponseEntity<TokenResponse>
|
||||||
|
.doOnError(e -> log.error("Login failed for user {}: " + "{}",
|
||||||
|
loginRequest.getUsername(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token verification requests. Expects the access token in the Authorization header
|
||||||
|
* (Bearer scheme). Returns full token introspection details.
|
||||||
|
*
|
||||||
|
* @param authorizationHeader The full Authorization header value (e.g., "Bearer token").
|
||||||
|
* @return ResponseEntity containing the VerificationResponse on success, or an error status
|
||||||
|
* handled globally.
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify")
|
||||||
|
public Mono<ResponseEntity<VerificationResponse>> verify(
|
||||||
|
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) {
|
||||||
|
Mono<ResponseEntity<VerificationResponse>> result;
|
||||||
|
log.info("Received token verification request (/verify)");
|
||||||
|
String accessToken = extractBearerToken(authorizationHeader);
|
||||||
|
if (accessToken == null) {
|
||||||
|
log.warn("Verification request missing or invalid Bearer token format," + " token : {}",
|
||||||
|
authorizationHeader);
|
||||||
|
result = Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||||
|
} else {
|
||||||
|
result = this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok)
|
||||||
|
.doOnError(e -> log.error("Token verification failed: {}", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the token from the "Bearer token" Authorization header.
|
||||||
|
*
|
||||||
|
* @param authorizationHeader The full header value.
|
||||||
|
* @return The token string or null if header is invalid.
|
||||||
|
*/
|
||||||
|
private String extractBearerToken(String authorizationHeader) {
|
||||||
|
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||||
|
return authorizationHeader.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles user logout requests using a refresh token.
|
||||||
|
*
|
||||||
|
* @param logoutRequest DTO containing the refresh token.
|
||||||
|
* @return ResponseEntity with 204 No Content on success, or an error status handled globally.
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public Mono<ResponseEntity<Void>> logout(@Valid @RequestBody LogoutRequest logoutRequest) {
|
||||||
|
log.info("Received logout request");
|
||||||
|
return this.identityManagerService.logout(logoutRequest.getRefreshToken())
|
||||||
|
.thenReturn(ResponseEntity.noContent().<Void>build())
|
||||||
|
.doOnError(e -> log.error("Logout failed: {}", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds HttpHeaders containing user metadata based on the verification response.
|
||||||
|
*
|
||||||
|
* @param verificationResponse The response from token verification.
|
||||||
|
* @return HttpHeaders object with X-User-* headers.
|
||||||
|
*/
|
||||||
|
private HttpHeaders buildAuthHeaders(VerificationResponse verificationResponse) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
// Use 'sub' (subject) as the canonical User ID
|
||||||
|
if (verificationResponse.getSub() != null) {
|
||||||
|
headers.add(HEADER_USER_ID, verificationResponse.getSub());
|
||||||
|
}
|
||||||
|
// Use 'preferredUsername' as the display/login name
|
||||||
|
if (verificationResponse.getPreferredUsername() != null) {
|
||||||
|
headers.add(HEADER_USER_NAME, verificationResponse.getPreferredUsername());
|
||||||
|
}
|
||||||
|
if (verificationResponse.getEmail() != null) {
|
||||||
|
headers.add(HEADER_USER_EMAIL, verificationResponse.getEmail());
|
||||||
|
}
|
||||||
|
// Handle groups if present
|
||||||
|
if (verificationResponse.getGroups() != null
|
||||||
|
&& !verificationResponse.getGroups().isEmpty()) {
|
||||||
|
// Convert list of groups to a comma-separated string
|
||||||
|
headers.add(HEADER_USER_GROUPS,
|
||||||
|
StringUtils.collectionToCommaDelimitedString(verificationResponse.getGroups()));
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, String> HTTP_METHOD_TO_SCOPE = Map.of(
|
||||||
|
"get", "rest:read",
|
||||||
|
"post", "rest:create",
|
||||||
|
"put", "rest:update",
|
||||||
|
"delete", "rest:delete"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles forward authentication requests from Traefik. Expects the access token in the
|
||||||
|
* Authorization header (Bearer scheme), and X-Forwarded-Method, X-Forwarded-Uri from Traefik.
|
||||||
|
* On success, returns 200 OK with user metadata in headers. On failure, returns 401
|
||||||
|
* Unauthorized or 403 Forbidden.
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/auth", method = {
|
||||||
|
RequestMethod.GET, RequestMethod.POST,
|
||||||
|
RequestMethod.PUT, RequestMethod.DELETE,
|
||||||
|
RequestMethod.PATCH})
|
||||||
|
public Mono<ResponseEntity<?>> forwardAuth(
|
||||||
|
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false)
|
||||||
|
String authorizationHeader,
|
||||||
|
@RequestHeader(HEADER_X_FORWARDED_METHOD) String originalMethod,
|
||||||
|
@RequestHeader(HEADER_X_FORWARDED_URI) String originalUriString
|
||||||
|
) {
|
||||||
|
log.info("Received forwardAuth request: Method='{}', Uri='{}'", originalMethod,
|
||||||
|
originalUriString);
|
||||||
|
// first detect the client id and service relative path
|
||||||
|
final var optionalRule = getPermissionCheckRuleByUri(originalUriString);
|
||||||
|
if (optionalRule.isEmpty()) {
|
||||||
|
log.warn("ForwardAuth: No Keycloak client mapping found for URI: {}",
|
||||||
|
originalUriString);
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
||||||
|
}
|
||||||
|
final var rule = optionalRule.get();
|
||||||
|
|
||||||
|
final var targetServiceClientId = rule.getKeycloakClientId();
|
||||||
|
final var serviceRelativePath =
|
||||||
|
extractServiceRelativePath(originalUriString, rule);
|
||||||
|
// then check pass-through rule
|
||||||
|
if (shouldPassThrough(rule, serviceRelativePath)) {
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.NO_CONTENT).build());
|
||||||
|
}
|
||||||
|
if (authorizationHeader == null) {
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST).build());
|
||||||
|
}
|
||||||
|
// no pass-through, check permission
|
||||||
|
final var accessToken = extractBearerToken(authorizationHeader);
|
||||||
|
if (accessToken == null) {
|
||||||
|
log.warn("ForwardAuth: Missing or invalid Bearer token format.");
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Validate the user's token and get user details (especially userSub)
|
||||||
|
return this.identityManagerService.verifyToken(accessToken)
|
||||||
|
.flatMap(verificationResponse -> {
|
||||||
|
// Token is valid and active, proceed to permission check
|
||||||
|
final var userSub = verificationResponse.getSub();
|
||||||
|
if (userSub == null || userSub.isBlank()) {
|
||||||
|
log.error(
|
||||||
|
"ForwardAuth: User subject (sub) "
|
||||||
|
+ "is missing from token after verification.");
|
||||||
|
return Mono.just(
|
||||||
|
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Figure out the scope
|
||||||
|
final var scope = HTTP_METHOD_TO_SCOPE.get(originalMethod.toLowerCase());
|
||||||
|
if (scope == null) { // reject if method not allowed
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check permission
|
||||||
|
return this.identityManagerService.checkResourcePermission(
|
||||||
|
accessToken, targetServiceClientId, serviceRelativePath, scope
|
||||||
|
)
|
||||||
|
.flatMap(hasPermission -> {
|
||||||
|
if (hasPermission) {
|
||||||
|
log.info(
|
||||||
|
"ForwardAuth: GRANTED for User='{}',"
|
||||||
|
+ " Resource='{}', Scope='{}', Client='{}'",
|
||||||
|
verificationResponse.getUsername(),
|
||||||
|
serviceRelativePath, scope,
|
||||||
|
targetServiceClientId);
|
||||||
|
final var responseHeaders = buildAuthHeaders(verificationResponse);
|
||||||
|
return Mono.just(ResponseEntity.ok().headers(responseHeaders)
|
||||||
|
.<Void>build());
|
||||||
|
} else {
|
||||||
|
log.warn(
|
||||||
|
"ForwardAuth: DENIED for User='{}',"
|
||||||
|
+ " Resource='{}', Scope='{}', Client='{}'",
|
||||||
|
verificationResponse.getUsername(),
|
||||||
|
serviceRelativePath, scope,
|
||||||
|
targetServiceClientId);
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.<Void>build());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).onErrorResume(TokenVerificationException.class, e -> {
|
||||||
|
log.warn("ForwardAuth: Token verification failed: {}", e.getMessage());
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build());
|
||||||
|
}).onErrorResume(throwable -> !(throwable instanceof TokenVerificationException),
|
||||||
|
t -> {
|
||||||
|
log.error(
|
||||||
|
"ForwardAuth: Generic error/exception during"
|
||||||
|
+ " permission processing: {}",
|
||||||
|
t.getMessage(), t);
|
||||||
|
return Mono.just(
|
||||||
|
ResponseEntity.status(HttpStatus.UNAUTHORIZED).<Void>build());
|
||||||
|
})
|
||||||
|
.onErrorResume(Exception.class, e -> { // Catch other unexpected errors
|
||||||
|
log.error("ForwardAuth: Internal error during permission check: {}",
|
||||||
|
e.getMessage(), e);
|
||||||
|
return Mono
|
||||||
|
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldPassThrough(
|
||||||
|
final PermissionMappingConfigProperties.Rule rule, final String serviceRelativePath
|
||||||
|
) {
|
||||||
|
final var passthroughs = rule.getPassthroughs();
|
||||||
|
try {
|
||||||
|
return passthroughs.stream()
|
||||||
|
.anyMatch(it -> executePassThroughRule(it, serviceRelativePath));
|
||||||
|
} catch (final Throwable throwable) {
|
||||||
|
log.error("Error executing pass-through rule for client {} : {}",
|
||||||
|
rule.getKeycloakClientId(), throwable.getMessage(), throwable);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean executePassThroughRule(
|
||||||
|
final PermissionMappingConfigProperties.PassThrough rule,
|
||||||
|
final String serviceRelativePath
|
||||||
|
) {
|
||||||
|
return switch (rule.getType()) {
|
||||||
|
case PATH_REGEX -> serviceRelativePath.matches(rule.getValue());
|
||||||
|
case PATH_PREFIX -> serviceRelativePath.startsWith(rule.getValue());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<PermissionMappingConfigProperties.Rule> getPermissionCheckRuleByUri(
|
||||||
|
String fullUriString
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
URI uri = new URI(fullUriString);
|
||||||
|
String path = uri.getPath(); // Get path part, e.g., /cleverswarm/api/jobs/123
|
||||||
|
log.debug("Mapping URI path: {}", path);
|
||||||
|
|
||||||
|
final var pathMatch = this.permissionMapLookupService.matchRuleByPath(path);
|
||||||
|
if (pathMatch.isPresent()) {
|
||||||
|
return pathMatch;
|
||||||
|
}
|
||||||
|
final var defaultValue = this.permissionMapLookupService.getDefaultKeycloakClientId();
|
||||||
|
if (defaultValue != null) {
|
||||||
|
log.debug(
|
||||||
|
"No rule matched for path '{}', using default Keycloak Client ID:{}",
|
||||||
|
path, defaultValue);
|
||||||
|
return Optional.of(PermissionMappingConfigProperties.Rule.builder()
|
||||||
|
.keycloakClientId(defaultValue)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
log.error("Invalid URI syntax for X-Forwarded-Uri: {}", fullUriString, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractServiceRelativePath(
|
||||||
|
String fullUriString, PermissionMappingConfigProperties.Rule rule
|
||||||
|
) {
|
||||||
|
String pathPrefixToRemove = rule.getPathPrefix();
|
||||||
|
if (rule.getKeycloakClientId()
|
||||||
|
.equals(this.permissionMapLookupService.getDefaultKeycloakClientId())) {
|
||||||
|
// If it's a default mapping, there's no prefix to remove, use the whole path.
|
||||||
|
// Or, you might decide that default mappings always apply to root paths. This
|
||||||
|
// needs definition.
|
||||||
|
// For now, assume full path if default.
|
||||||
|
try {
|
||||||
|
return new URI(fullUriString).getPath();
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
log.error(
|
||||||
|
"Invalid URI syntax for X-Forwarded-Uri when extracting relative path:"
|
||||||
|
+ " {}",
|
||||||
|
fullUriString, e);
|
||||||
|
return ""; // Or throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = new URI(fullUriString);
|
||||||
|
String path = uri.getPath();
|
||||||
|
if (path.startsWith(pathPrefixToRemove)) {
|
||||||
|
String relativePath = path.substring(pathPrefixToRemove.length());
|
||||||
|
// Ensure it starts with a slash if not empty
|
||||||
|
return relativePath.startsWith("/") || relativePath.isEmpty() ? relativePath
|
||||||
|
: "/" + relativePath;
|
||||||
|
}
|
||||||
|
return path; // Fallback
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
log.error("Invalid URI syntax for X-Forwarded-Uri when extracting relative path: {}",
|
||||||
|
fullUriString, e);
|
||||||
|
return ""; // Or throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package com.cleverthis.authservice.controller;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller for managing fine-grained groups (departments, teams) within
|
||||||
|
* the context of a parent organization.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/groups")
|
||||||
|
@Slf4j
|
||||||
|
public class IntraOrganizationGroupController {
|
||||||
|
|
||||||
|
private final IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the controller with the required service dependency.
|
||||||
|
*
|
||||||
|
* @param identityManagerService The service handling identity management logic.
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public IntraOrganizationGroupController(final IdentityManagerService identityManagerService) {
|
||||||
|
this.identityManagerService = identityManagerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new sub-group within a parent organization.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization.
|
||||||
|
* @param request The request body containing details for the new group.
|
||||||
|
* @return A Mono emitting the created group's representation.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{orgId}/children")
|
||||||
|
public Mono<ResponseEntity<GroupResponse>> createSubGroup(@PathVariable final String orgId,
|
||||||
|
@Valid @RequestBody final CreateGroupRequest request) {
|
||||||
|
log.info("Received request to create sub-group with name '{}' in organization '{}'",
|
||||||
|
request.getName(), orgId);
|
||||||
|
return this.identityManagerService.createSubGroup(orgId, request)
|
||||||
|
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all sub-groups within a parent organization.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization.
|
||||||
|
* @return A Mono emitting a list of group representations.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{orgId}/children")
|
||||||
|
public Mono<ResponseEntity<List<GroupResponse>>> listSubGroups(
|
||||||
|
@PathVariable final String orgId) {
|
||||||
|
log.info("Received request to list sub-groups for organization '{}'", orgId);
|
||||||
|
return this.identityManagerService.listSubGroups(orgId).map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single sub-group by its ID.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization (for context).
|
||||||
|
* @param groupId The ID of the sub-group to retrieve.
|
||||||
|
* @return A Mono emitting the group's representation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{orgId}/children/{groupId}")
|
||||||
|
public Mono<ResponseEntity<GroupResponse>> getSubGroupById(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId) {
|
||||||
|
log.info("Received request to get sub-group '{}' from organization '{}'", groupId, orgId);
|
||||||
|
return this.identityManagerService.getGroupById(groupId).map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing sub-group.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization (for context).
|
||||||
|
* @param groupId The ID of the sub-group to update.
|
||||||
|
* @param request The request body with fields to update.
|
||||||
|
* @return A Mono completing with a 204 No Content response.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{orgId}/children/{groupId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> updateSubGroup(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId,
|
||||||
|
@Valid @RequestBody final UpdateGroupRequest request) {
|
||||||
|
log.info("Received request to update sub-group '{}' in organization '{}'", groupId, orgId);
|
||||||
|
return this.identityManagerService.updateGroup(groupId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a sub-group.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization (for context).
|
||||||
|
* @param groupId The ID of the sub-group to delete.
|
||||||
|
* @return A Mono completing with a 204 No Content response.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{orgId}/children/{groupId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> deleteSubGroup(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId) {
|
||||||
|
log.info("Received request to delete sub-group '{}' from organization '{}'",
|
||||||
|
groupId, orgId);
|
||||||
|
return this.identityManagerService.deleteGroup(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-Group Membership Management ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an existing organization member to a sub-group.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization.
|
||||||
|
* @param groupId The ID of the sub-group.
|
||||||
|
* @param request The request body containing the user's ID.
|
||||||
|
* @return A Mono completing with a 204 No Content response.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{orgId}/children/{groupId}/members")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> addMemberToSubGroup(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId,
|
||||||
|
@Valid @RequestBody final AddGroupMemberRequest request) {
|
||||||
|
log.info("Received request to add user '{}' to sub-group '{}' in organization '{}'",
|
||||||
|
request.getUserId(), groupId, orgId);
|
||||||
|
return this.identityManagerService.addMemberToGroup(groupId, request.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all members of a specific sub-group.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization.
|
||||||
|
* @param groupId The ID of the sub-group.
|
||||||
|
* @return A Mono emitting a list of organization member representations.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{orgId}/children/{groupId}/members")
|
||||||
|
public Mono<ResponseEntity<List<OrganizationMemberResponse>>>
|
||||||
|
listSubGroupMembers(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId) {
|
||||||
|
log.info("Received request to list members of sub-group '{}' in organization '{}'",
|
||||||
|
groupId, orgId);
|
||||||
|
return this.identityManagerService.getGroupMembers(groupId).map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user from a sub-group.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the parent organization.
|
||||||
|
* @param groupId The ID of the sub-group.
|
||||||
|
* @param userId The ID of the user to remove.
|
||||||
|
* @return A Mono completing with a 204 No Content response.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{orgId}/children/{groupId}/members/{userId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> removeMemberFromSubGroup(@PathVariable final String orgId,
|
||||||
|
@PathVariable final String groupId, @PathVariable final String userId) {
|
||||||
|
log.info("Received request to remove user '{}' from sub-group '{}' in organization '{}'",
|
||||||
|
userId, groupId, orgId);
|
||||||
|
return this.identityManagerService.removeMemberFromGroup(groupId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.cleverthis.authservice.controller;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller for handling Organization related requests.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/organizations")
|
||||||
|
@Slf4j
|
||||||
|
public class OrganizationController {
|
||||||
|
|
||||||
|
private final IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public OrganizationController(IdentityManagerService identityManagerService) {
|
||||||
|
this.identityManagerService = identityManagerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Organization. Requires administrative privileges, which should be enforced by
|
||||||
|
* the /auth endpoint.
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<OrganizationResponse>> createOrganization(
|
||||||
|
@Valid @RequestBody CreateOrganizationRequest request) {
|
||||||
|
log.info("Received request to create organization with name: {}", request.getName());
|
||||||
|
return this.identityManagerService.createOrganization(request)
|
||||||
|
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a list of all Organizations.
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public Mono<ResponseEntity<List<OrganizationResponse>>> getOrganizations() {
|
||||||
|
log.info("Received request to list all organizations");
|
||||||
|
return this.identityManagerService.getOrganizations().map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single Organization by its ID.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{orgId}")
|
||||||
|
public Mono<ResponseEntity<OrganizationResponse>> getOrganizationById(
|
||||||
|
@PathVariable String orgId) {
|
||||||
|
log.info("Received request to get organization by ID: {}", orgId);
|
||||||
|
return this.identityManagerService.getOrganizationById(orgId).map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing Organization.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{orgId}")
|
||||||
|
public Mono<ResponseEntity<Void>> updateOrganization(@PathVariable String orgId,
|
||||||
|
@Valid @RequestBody UpdateOrganizationRequest request) {
|
||||||
|
log.info("Received request to update organization ID: {}", orgId);
|
||||||
|
return this.identityManagerService.updateOrganization(orgId, request)
|
||||||
|
.thenReturn(ResponseEntity.noContent().<Void>build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an Organization.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{orgId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> deleteOrganization(@PathVariable String orgId) {
|
||||||
|
log.info("Received request to delete organization ID: {}", orgId);
|
||||||
|
return this.identityManagerService.deleteOrganization(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Membership Management ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invites a user to join an Organization via email.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{orgId}/invitations")
|
||||||
|
public Mono<ResponseEntity<Void>> inviteUserToOrganization(@PathVariable String orgId,
|
||||||
|
@Valid @RequestBody InviteUserRequest request) {
|
||||||
|
log.info("Received request to invite user with email {} to organization ID: {}",
|
||||||
|
request.getEmail(), orgId);
|
||||||
|
return this.identityManagerService.inviteUserToOrganization(orgId, request)
|
||||||
|
.thenReturn(ResponseEntity.ok().<Void>build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all members of an Organization.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{orgId}/members")
|
||||||
|
public Mono<ResponseEntity<List<OrganizationMemberResponse>>> getOrganizationMembers(
|
||||||
|
@PathVariable String orgId) {
|
||||||
|
log.info("Received request to get members of organization ID: {}", orgId);
|
||||||
|
return this.identityManagerService.getOrganizationMembers(orgId).map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an existing user to an Organization.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{orgId}/members")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> addMemberToOrganization(@PathVariable String orgId,
|
||||||
|
@Valid @RequestBody AddMemberRequest request) {
|
||||||
|
log.info("Received request to add user ID {} to organization ID: {}", request.getUserId(),
|
||||||
|
orgId);
|
||||||
|
return this.identityManagerService.addMemberToOrganization(orgId, request.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user from an Organization.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{orgId}/members/{userId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> removeMemberFromOrganization(@PathVariable String orgId,
|
||||||
|
@PathVariable String userId) {
|
||||||
|
log.info("Received request to remove user ID {} from organization ID: {}", userId, orgId);
|
||||||
|
return this.identityManagerService.removeMemberFromOrganization(orgId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.cleverthis.authservice.controller;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST Controller for handling registration related requests.
|
||||||
|
* provider interaction to the IdentityManagerService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/users")
|
||||||
|
@Slf4j
|
||||||
|
public class UserRegistrationController {
|
||||||
|
|
||||||
|
private final IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UserRegistrationController(IdentityManagerService identityManagerService) {
|
||||||
|
this.identityManagerService = identityManagerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles new user registration requests. The user will be created with emailVerified set to
|
||||||
|
* false. A conceptual verification email would be sent (implementation of email sending is
|
||||||
|
* TBD).
|
||||||
|
*
|
||||||
|
* @param registrationRequest DTO containing user registration details.
|
||||||
|
* @return ResponseEntity with 201 Created on success, or an error status.
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
public Mono<ResponseEntity<Void>> registerUser(
|
||||||
|
@Valid @RequestBody RegistrationRequest registrationRequest) {
|
||||||
|
log.info("Received registration request for email: {}", registrationRequest.getEmail());
|
||||||
|
return this.identityManagerService.registerUser(registrationRequest)
|
||||||
|
.then(Mono.fromCallable(
|
||||||
|
() -> ResponseEntity.status(HttpStatus.CREATED).<Void>build()))
|
||||||
|
.doOnError(e -> log.error("User registration failed for email {}: {}",
|
||||||
|
registrationRequest.getEmail(), e.getMessage()))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("Registration failed with an unexpected error: {}", e.getMessage());
|
||||||
|
return Mono
|
||||||
|
.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the Keycloak Admin token response (Client Credentials Grant).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class KeycloakAdminTokenResponse {
|
||||||
|
@JsonProperty("access_token")
|
||||||
|
private String accessToken;
|
||||||
|
@JsonProperty("expires_in")
|
||||||
|
private int expiresIn;
|
||||||
|
@JsonProperty("refresh_expires_in")
|
||||||
|
private int refreshExpiresIn;
|
||||||
|
@JsonProperty("token_type")
|
||||||
|
private String tokenType;
|
||||||
|
@JsonProperty("not-before-policy")
|
||||||
|
private int notBeforePolicy;
|
||||||
|
@JsonProperty("scope")
|
||||||
|
private String scope;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO to represent a Keycloak Client when fetched from Admin API.
|
||||||
|
* We need this to get the internal 'id' (UUID) of a client from its 'clientId'.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class KeycloakClientRepresentation {
|
||||||
|
private String id; // This is the internal UUID
|
||||||
|
@JsonProperty("client_Id") // Maps the JSON field 'clientId' to this Java field
|
||||||
|
private String publicClientId; // The human-readable client_id
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private boolean enabled;
|
||||||
|
private List<String> defaultRoles;
|
||||||
|
private Map<String, String> attributes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO to represent a Keycloak Role when fetched from Admin API.
|
||||||
|
* We are primarily interested in the 'name' of the role.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class KeycloakRoleRepresentation {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private Boolean composite;
|
||||||
|
private Boolean clientRole;
|
||||||
|
private String containerId; // For client roles, this is the client's internal ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the /login request body.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LoginRequest {
|
||||||
|
@NotBlank(message = "Username cannot be blank")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "Password cannot be blank")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the /logout request body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LogoutRequest {
|
||||||
|
@NotBlank(message = "Refresh token cannot be blank")
|
||||||
|
private String refreshToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the /register request body.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RegistrationRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "First name cannot be blank")
|
||||||
|
@Size(min = 1, max = 50, message = "First name must be between 1 and 50 characters")
|
||||||
|
private String firstName;
|
||||||
|
|
||||||
|
@NotBlank(message = "Last name cannot be blank")
|
||||||
|
@Size(min = 1, max = 50, message = "Last name must be between 1 and 50 characters")
|
||||||
|
private String lastName;
|
||||||
|
|
||||||
|
@NotBlank(message = "Email cannot be blank")
|
||||||
|
@Email(message = "Email should be valid")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank(message = "Password cannot be blank")
|
||||||
|
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||||
|
// Add more password complexity validation if needed via custom validator or regex pattern
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
// Optional: username, if different from email and the Keycloak setup requires it
|
||||||
|
// @NotBlank(message = "Username cannot be blank")
|
||||||
|
// private String username;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing a successful token response from Keycloak (or similar). Contains the fields
|
||||||
|
* typically returned by an OAuth2 token endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TokenResponse {
|
||||||
|
|
||||||
|
@JsonProperty("access_token")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
@JsonProperty("expires_in")
|
||||||
|
private int expiresIn;
|
||||||
|
|
||||||
|
@JsonProperty("refresh_expires_in")
|
||||||
|
private int refreshExpiresIn;
|
||||||
|
|
||||||
|
@JsonProperty("refresh_token")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
@JsonProperty("token_type")
|
||||||
|
private String tokenType;
|
||||||
|
|
||||||
|
@JsonProperty("id_token")
|
||||||
|
private String idToken;
|
||||||
|
|
||||||
|
@JsonProperty("not-before-policy")
|
||||||
|
private int notBeforePolicy;
|
||||||
|
|
||||||
|
@JsonProperty("session_state")
|
||||||
|
private String sessionState;
|
||||||
|
|
||||||
|
@JsonProperty("scope")
|
||||||
|
private String scope;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.cleverthis.authservice.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the response from Keycloak's token introspection endpoint.
|
||||||
|
* Also serves as the response for our /verify endpoint and basis for /auth headers.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class VerificationResponse {
|
||||||
|
private boolean active; // Standard introspection field: is the token active?
|
||||||
|
@JsonProperty("client_id")
|
||||||
|
private String clientId;
|
||||||
|
private String username; // Often same as preferred_username
|
||||||
|
private String scope;
|
||||||
|
private String sub; // Subject (user ID) - Use this for X-User-Id
|
||||||
|
private long exp; // Expiration timestamp
|
||||||
|
private long iat; // Issued at timestamp
|
||||||
|
private long nbf; // Not before timestamp
|
||||||
|
private List<String> aud; // Audience - Can be a list if token has multiple audiences
|
||||||
|
private String iss; // Issuer
|
||||||
|
private String typ; // Type (e.g., Bearer)
|
||||||
|
private String email; // Standard OIDC claim
|
||||||
|
@JsonProperty("email_verified")
|
||||||
|
private Boolean emailVerified; // Standard OIDC claim
|
||||||
|
@JsonProperty("preferred_username")
|
||||||
|
private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name
|
||||||
|
private List<String> groups;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.cleverthis.authservice.dto.group;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for adding an existing user to a group.
|
||||||
|
* This can be reused from the organization membership logic.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AddGroupMemberRequest {
|
||||||
|
@NotBlank(message = "User ID cannot be blank")
|
||||||
|
private String userId; // The Keycloak user ID (sub)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.cleverthis.authservice.dto.group; // New package for group-related DTOs
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the request body when creating a new sub-group (e.g., department, team)
|
||||||
|
* within an organization.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CreateGroupRequest {
|
||||||
|
@NotBlank(message = "Group name cannot be blank")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.cleverthis.authservice.dto.group;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for representing a group in API responses.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class GroupResponse {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String path; // The full path of the group (e.g., /tenant-org/departments/engineering)
|
||||||
|
private String description;
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.cleverthis.authservice.dto.group;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the request body when updating a sub-group.
|
||||||
|
* The name of a group is typically not updatable via this DTO; it's part of the path.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UpdateGroupRequest {
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for representing a Keycloak Group when interacting
|
||||||
|
* with the Keycloak Admin API.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class KeycloakGroupRepresentation {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String path;
|
||||||
|
private String description; // Note: Description is often stored in attributes
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
private List<KeycloakGroupRepresentation> subGroups;
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for creating an invitation request to join an organization.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class KeycloakInvitationRequest {
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
// You can also specify roles to be redirected to after accepting
|
||||||
|
// private List<String> redirectRoles;
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
|
||||||
|
* Admin API.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
// Include only non-null fields during serialization, useful for update operations
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class KeycloakOrganizationRepresentation {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private Set<OrganizationDomainRepresentation> domains;
|
||||||
|
private String description;
|
||||||
|
private Boolean enabled;
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.cleverthis.authservice.dto.keycloakadmin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
|
||||||
|
* organization. It includes only the most relevant fields.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class KeycloakUserRepresentation {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String username;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String email;
|
||||||
|
private boolean enabled;
|
||||||
|
private Long createdTimestamp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for adding an existing user to an Organization.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor public class AddMemberRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "User ID cannot be blank")
|
||||||
|
private String userId; // The Keycloak user ID (sub)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the request body when creating a new Organization.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CreateOrganizationRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "Organization name cannot be blank")
|
||||||
|
private String name; // This will become the internal name in Keycloak
|
||||||
|
|
||||||
|
@NotEmpty(message = "At least one organization domain must be provided")
|
||||||
|
private List<String> domains;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Map<String, List<String>> attributes; // For custom metadata like displayName
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for inviting a new or existing user to an Organization via email.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor public class InviteUserRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "Email cannot be blank")
|
||||||
|
@Email(message = "A valid email address is required")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
// Optional fields if inviting a brand new user
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
|
||||||
|
// You could also add a list of roles/groups to assign upon joining
|
||||||
|
// private List<String> rolesToAssign;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
|
||||||
|
* relevant fields.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor public class OrganizationMemberResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String username;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String email;
|
||||||
|
private boolean enabled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for representing an Organization in API responses.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class OrganizationResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private Set<OrganizationDomainRepresentation> domains;
|
||||||
|
private String description;
|
||||||
|
private Boolean enabled;
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.cleverthis.authservice.dto.organization;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for the request body when updating an Organization.
|
||||||
|
* Name is typically not updatable, but description and attributes are.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UpdateOrganizationRequest {
|
||||||
|
private List<String> domains;
|
||||||
|
private String description;
|
||||||
|
private Map<String, List<String>> attributes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception for authentication failures.
|
||||||
|
*/
|
||||||
|
public class AuthenticationException extends RuntimeException {
|
||||||
|
|
||||||
|
public AuthenticationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Exception Handler to translate custom exceptions into appropriate HTTP responses using
|
||||||
|
* Problem Details (RFC 7807).
|
||||||
|
*/
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link AuthenticationException} to HTTP 401.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthenticationException.class)
|
||||||
|
public ProblemDetail handleAuthenticationException(AuthenticationException ex) {
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Authentication Failed");
|
||||||
|
// Add more details if needed
|
||||||
|
// problemDetail.setProperty("details", "Check username/password or Keycloak status.");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link TokenVerificationException} to HTTP 401.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(TokenVerificationException.class)
|
||||||
|
public ProblemDetail handleTokenVerificationException(TokenVerificationException ex) {
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Token Verification Failed");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link LogoutException} to HTTP 400 or 500.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(LogoutException.class)
|
||||||
|
public ProblemDetail handleLogoutException(LogoutException ex) {
|
||||||
|
// Depending on the cause, might return 400 or 500
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Logout Failed");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link PermissionCheckException} to HTTP 403.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(PermissionCheckException.class) // New Handler
|
||||||
|
public ProblemDetail handlePermissionCheckException(PermissionCheckException ex) {
|
||||||
|
// Usually a permission check failure should result in 403 Forbidden
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Access Denied");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link ServiceUnavailableException} to HTTP 503.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(ServiceUnavailableException.class) // New Handler
|
||||||
|
public ProblemDetail handleServiceUnavailableException(ServiceUnavailableException ex) {
|
||||||
|
// Error communicating with Keycloak Admin API or other critical backend
|
||||||
|
this.logger.error("Service unavailable exception: {}", ex); // Log with stack trace
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"An external service required for authorization is currently unavailable.");
|
||||||
|
problemDetail.setTitle("Service Unavailable");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link UserAlreadyExistsException} to HTTP 409.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(UserAlreadyExistsException.class)
|
||||||
|
public ProblemDetail handleUserAlreadyExistsException(UserAlreadyExistsException ex) {
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
|
||||||
|
problemDetail.setTitle("User Registration Conflict");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link RegistrationException} to HTTP 500.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(RegistrationException.class)
|
||||||
|
public ProblemDetail handleRegistrationException(RegistrationException ex) {
|
||||||
|
// Could be BAD_REQUEST or INTERNAL_SERVER_ERROR depending on the cause
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||||
|
problemDetail.setTitle("User Registration Failed");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles ResourceNotFoundException and translates it to a 404 Not Found response.
|
||||||
|
*
|
||||||
|
* @param ex The ResourceNotFoundException that was thrown.
|
||||||
|
* @return A ProblemDetail object for a 404 response.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(ResourceNotFoundException.class)
|
||||||
|
public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
|
||||||
|
ProblemDetail problemDetail =
|
||||||
|
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||||
|
problemDetail.setTitle("Resource Not Found");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ProblemDetail handleGenericException(Exception ex) {
|
||||||
|
this.logger.error("An internal error occurred.: ", ex); // Log the full stack trace
|
||||||
|
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR, "An internal error occurred.");
|
||||||
|
problemDetail.setTitle("Internal Server Error");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception for logout failures.
|
||||||
|
*/
|
||||||
|
public class LogoutException extends RuntimeException {
|
||||||
|
|
||||||
|
public LogoutException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LogoutException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception for errors during permission checks.
|
||||||
|
*/
|
||||||
|
public class PermissionCheckException extends RuntimeException {
|
||||||
|
public PermissionCheckException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PermissionCheckException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrationException.
|
||||||
|
*/
|
||||||
|
public class RegistrationException extends RuntimeException {
|
||||||
|
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 2301783309630884721L;
|
||||||
|
|
||||||
|
public RegistrationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegistrationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception to be thrown when a requested resource is not found.
|
||||||
|
* The @ResponseStatus annotation tells Spring to automatically return a 404 Not Found
|
||||||
|
* status when this exception is thrown from a controller and not handled by an
|
||||||
|
* exception handler. This can simplify the GlobalExceptionHandler.
|
||||||
|
*/
|
||||||
|
@ResponseStatus(value = HttpStatus.NOT_FOUND)
|
||||||
|
public class ResourceNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new ResourceNotFoundException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message.
|
||||||
|
*/
|
||||||
|
public ResourceNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception for when a required external service (like Keycloak Admin API)
|
||||||
|
* is unavailable or returns an error.
|
||||||
|
*/
|
||||||
|
public class ServiceUnavailableException extends RuntimeException {
|
||||||
|
public ServiceUnavailableException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ServiceUnavailableException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom exception for token verification failures.
|
||||||
|
*/
|
||||||
|
public class TokenVerificationException extends RuntimeException {
|
||||||
|
|
||||||
|
public TokenVerificationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenVerificationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserAlreadyExistsException.
|
||||||
|
*/
|
||||||
|
public class UserAlreadyExistsException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 2297555492902445706L;
|
||||||
|
|
||||||
|
public UserAlreadyExistsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserAlreadyExistsException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.cleverthis.authservice.service;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
|
import java.util.List;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining operations for interacting with an identity provider. This abstraction allows
|
||||||
|
* swapping the underlying implementation (e.g., Keycloak, Okta) without changing the controller
|
||||||
|
* logic.
|
||||||
|
*/
|
||||||
|
public interface IdentityManagerService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user using username and password (Resource Owner Password Credentials Grant).
|
||||||
|
*
|
||||||
|
* @param username The user's username.
|
||||||
|
* @param password The user's password.
|
||||||
|
* @return A Mono emitting the TokenResponse upon successful authentication. Emits an error
|
||||||
|
* (e.g., AuthenticationException) if authentication fails.
|
||||||
|
*/
|
||||||
|
Mono<TokenResponse> login(String username, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the validity of an access token and retrieves associated user information.
|
||||||
|
*
|
||||||
|
* @param accessToken The access token (without "Bearer " prefix).
|
||||||
|
* @return A Mono emitting the VerificationResponse containing token details and user info.
|
||||||
|
* Emits an error if the token is invalid, expired, or introspection fails.
|
||||||
|
*/
|
||||||
|
Mono<VerificationResponse> verifyToken(String accessToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out a user session by revoking the refresh token.
|
||||||
|
*
|
||||||
|
* @param refreshToken The refresh token associated with the user session.
|
||||||
|
* @return A Mono completing successfully upon successful logout/revocation. Emits an error if
|
||||||
|
* revocation fails.
|
||||||
|
*/
|
||||||
|
Mono<Void> logout(String refreshToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user in the identity provider.
|
||||||
|
* The user is typically created with emailVerified set to false.
|
||||||
|
* The implementation is responsible for initiating the email verification process
|
||||||
|
* (e.g., generating a token, triggering an email).
|
||||||
|
*
|
||||||
|
* @param registrationRequest DTO containing user registration details.
|
||||||
|
* @return A Mono completing successfully if registration initiation is successful.
|
||||||
|
* Emits an error (e.g., UserAlreadyExistsException) if registration fails.
|
||||||
|
*/
|
||||||
|
Mono<Void> registerUser(RegistrationRequest registrationRequest);
|
||||||
|
|
||||||
|
Mono<Boolean> checkResourcePermission(
|
||||||
|
String reqAccessToken, String clientId, String resourceUri, String scope
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Organization in Keycloak.
|
||||||
|
*
|
||||||
|
* @param request DTO with details for the new organization.
|
||||||
|
* @return A Mono emitting the newly created Organization's representation.
|
||||||
|
*/
|
||||||
|
Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all Organizations from Keycloak.
|
||||||
|
*
|
||||||
|
* @return A Mono emitting a list of Organization representations.
|
||||||
|
*/
|
||||||
|
Mono<List<OrganizationResponse>> getOrganizations();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single Organization by its ID from Keycloak.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization to retrieve.
|
||||||
|
* @return A Mono emitting the Organization's representation.
|
||||||
|
*/
|
||||||
|
Mono<OrganizationResponse> getOrganizationById(String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing Organization in Keycloak.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization to update.
|
||||||
|
* @param request DTO with the fields to update.
|
||||||
|
* @return A Mono completing when the update is successful.
|
||||||
|
*/
|
||||||
|
Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an Organization from Keycloak.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization to delete.
|
||||||
|
* @return A Mono completing when the deletion is successful.
|
||||||
|
*/
|
||||||
|
Mono<Void> deleteOrganization(String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invites a user to join an Organization. Keycloak handles the email sending.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization to invite the user to.
|
||||||
|
* @param request DTO containing the user's email and other details.
|
||||||
|
* @return A Mono completing when the invitation is successfully sent.
|
||||||
|
*/
|
||||||
|
Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all members of an Organization.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization.
|
||||||
|
* @return A Mono emitting a list of members.
|
||||||
|
*/
|
||||||
|
Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an existing user to an Organization.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization.
|
||||||
|
* @param userId The ID of the user to add.
|
||||||
|
* @return A Mono completing when the user is successfully added.
|
||||||
|
*/
|
||||||
|
Mono<Void> addMemberToOrganization(String orgId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user from an Organization.
|
||||||
|
*
|
||||||
|
* @param orgId The ID of the organization.
|
||||||
|
* @param userId The ID of the user to remove.
|
||||||
|
* @return A Mono completing when the user is successfully removed.
|
||||||
|
*/
|
||||||
|
Mono<Void> removeMemberFromOrganization(String orgId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new sub-group as a child of a specified parent group.
|
||||||
|
*
|
||||||
|
* @param parentGroupId The ID of the parent group.
|
||||||
|
* @param request DTO containing the details for the new sub-group.
|
||||||
|
* @return A Mono emitting the representation of the created group.
|
||||||
|
*/
|
||||||
|
Mono<GroupResponse> createSubGroup(String parentGroupId, CreateGroupRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a single group by its unique ID.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group to retrieve.
|
||||||
|
* @return A Mono emitting the group's representation.
|
||||||
|
*/
|
||||||
|
Mono<GroupResponse> getGroupById(String groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all direct sub-groups (children) of a given parent group.
|
||||||
|
*
|
||||||
|
* @param parentGroupId The ID of the parent group.
|
||||||
|
* @return A Mono emitting a list of group representations.
|
||||||
|
*/
|
||||||
|
Mono<List<GroupResponse>> listSubGroups(String parentGroupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing group's details.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group to update.
|
||||||
|
* @param request DTO containing the fields to update.
|
||||||
|
* @return A Mono that completes when the update is successful.
|
||||||
|
*/
|
||||||
|
Mono<Void> updateGroup(String groupId, UpdateGroupRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a group by its ID.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group to delete.
|
||||||
|
* @return A Mono that completes when the deletion is successful.
|
||||||
|
*/
|
||||||
|
Mono<Void> deleteGroup(String groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an existing user to a group.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group.
|
||||||
|
* @param userId The ID of the user to add.
|
||||||
|
* @return A Mono that completes when the user is successfully added.
|
||||||
|
*/
|
||||||
|
Mono<Void> addMemberToGroup(String groupId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all members of a specific group.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group.
|
||||||
|
* @return A Mono emitting a list of member representations.
|
||||||
|
*/
|
||||||
|
Mono<List<OrganizationMemberResponse>> getGroupMembers(String groupId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a user from a specific group.
|
||||||
|
*
|
||||||
|
* @param groupId The ID of the group.
|
||||||
|
* @param userId The ID of the user to remove.
|
||||||
|
* @return A Mono that completes when the user is successfully removed.
|
||||||
|
*/
|
||||||
|
Mono<Void> removeMemberFromGroup(String groupId, String userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.cleverthis.authservice.service;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for looking up the permission map.
|
||||||
|
*/
|
||||||
|
public interface PermissionMapLookupService {
|
||||||
|
/**
|
||||||
|
* The default client id when no rules matched.
|
||||||
|
* If not provided, might be null or empty.
|
||||||
|
*/
|
||||||
|
String getDefaultKeycloakClientId();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the path, find the client id of the first rule matching the path prefix.
|
||||||
|
*/
|
||||||
|
Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(String path);
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package com.cleverthis.authservice.service.impl;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
|
import com.cleverthis.authservice.service.PermissionMapLookupService;
|
||||||
|
import com.ecwid.consul.v1.ConsulClient;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PermissionMapLookupService} with fallback.
|
||||||
|
* <p>
|
||||||
|
* It will first look up consul, if not available,
|
||||||
|
* fallback to {@code application.yaml}
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@EnableScheduling
|
||||||
|
public final class FallbackPermissionMapLookupService implements PermissionMapLookupService {
|
||||||
|
/**
|
||||||
|
* The consul kv key for permission mapping.
|
||||||
|
*/
|
||||||
|
static final String PERMISSION_MAPPING_CONSUL_KEY =
|
||||||
|
"cleverthis/clevermicro/auth-service/config/permission-mapping";
|
||||||
|
private final PermissionMappingConfigProperties permissionMappingConfigProperties;
|
||||||
|
private final ConsulClient consulClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an instance of {@link FallbackPermissionMapLookupService}.
|
||||||
|
*
|
||||||
|
* @param permissionMappingConfigProperties the local config, used as fallback.
|
||||||
|
* @param consulClient the consul client, used for query consul value.
|
||||||
|
* @param objectMapper for deserializing the value from consul.
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public FallbackPermissionMapLookupService(
|
||||||
|
final PermissionMappingConfigProperties permissionMappingConfigProperties,
|
||||||
|
final ConsulClient consulClient,
|
||||||
|
final ObjectMapper objectMapper
|
||||||
|
) {
|
||||||
|
this.permissionMappingConfigProperties = permissionMappingConfigProperties;
|
||||||
|
this.consulClient = consulClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
refreshConsul(); // initial refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AtomicReference<PermissionMappingConfigProperties> consulValue =
|
||||||
|
new AtomicReference<>();
|
||||||
|
|
||||||
|
@Scheduled(initialDelay = 1000, fixedRate = 1000)
|
||||||
|
void refreshConsul() {
|
||||||
|
try {
|
||||||
|
// clear cache
|
||||||
|
this.consulValue.set(null);
|
||||||
|
// fetch value
|
||||||
|
final var resp = this.consulClient.getKVValue(PERMISSION_MAPPING_CONSUL_KEY);
|
||||||
|
if (resp == null) {
|
||||||
|
return; // skip if no response
|
||||||
|
}
|
||||||
|
final var kvValue = resp.getValue();
|
||||||
|
if (kvValue.getDecodedValue() == null) {
|
||||||
|
// skip if no value provided
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { // try decoding
|
||||||
|
final var config = this.objectMapper.readValue(
|
||||||
|
kvValue.getDecodedValue(), PermissionMappingConfigProperties.class);
|
||||||
|
this.consulValue.set(config);
|
||||||
|
} catch (final JsonProcessingException ex) {
|
||||||
|
// got a corrupted value, leaving the cache empty
|
||||||
|
// will fall back to local value when query
|
||||||
|
log.error("Corrupted config value from consul for key {}: {}",
|
||||||
|
PERMISSION_MAPPING_CONSUL_KEY, kvValue.getValue(), ex);
|
||||||
|
}
|
||||||
|
} catch (final Throwable ex) {
|
||||||
|
// catch-all
|
||||||
|
log.error("Unknown error when updating consul value for permission mapping", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PermissionMappingConfigProperties getConfig() {
|
||||||
|
final var consulValue = this.consulValue.get();
|
||||||
|
if (consulValue != null) {
|
||||||
|
return consulValue;
|
||||||
|
} else {
|
||||||
|
return this.permissionMappingConfigProperties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDefaultKeycloakClientId() {
|
||||||
|
return getConfig().getDefaultKeycloakClientId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PermissionMappingConfigProperties.Rule> matchRuleByPath(final String path) {
|
||||||
|
final var config = getConfig();
|
||||||
|
for (final var rule : config.getRules()) {
|
||||||
|
if (path.startsWith(rule.getPathPrefix())) {
|
||||||
|
log.debug("Path '{}' matched prefix '{}', using Keycloak Client ID: {}",
|
||||||
|
path, rule.getPathPrefix(), rule.getKeycloakClientId());
|
||||||
|
return Optional.of(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
package com.cleverthis.authservice.service.impl;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
|
||||||
|
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||||
|
import com.cleverthis.authservice.exception.LogoutException;
|
||||||
|
import com.cleverthis.authservice.exception.PermissionCheckException;
|
||||||
|
import com.cleverthis.authservice.exception.ServiceUnavailableException;
|
||||||
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keycloak-specific implementation of the IdentityManagerService. Uses WebClient to interact with
|
||||||
|
* Keycloak's REST API endpoints.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class KeycloakIdentityManagerService implements IdentityManagerService {
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final KeycloakAdminReactiveClient keycloakAdminClient; // Inject KeycloakAdminClient
|
||||||
|
|
||||||
|
private final String keycloakServerUrl;
|
||||||
|
private final String realm;
|
||||||
|
private final String clientId;
|
||||||
|
private final String clientSecret;
|
||||||
|
|
||||||
|
private String getTokenEndpoint() {
|
||||||
|
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
|
||||||
|
.path("/realms/{realm}/protocol/openid-connect/token")
|
||||||
|
.buildAndExpand(this.realm)
|
||||||
|
.encode().toUriString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getIntrospectionEndpoint() {
|
||||||
|
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
|
||||||
|
.path("/realms/{realm}/protocol/openid-connect/token/introspect")
|
||||||
|
.buildAndExpand(this.realm)
|
||||||
|
.encode().toUriString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRevocationEndpoint() {
|
||||||
|
return UriComponentsBuilder.fromUriString(this.keycloakServerUrl)
|
||||||
|
.path("/realms/{realm}/protocol/openid-connect/revoke")
|
||||||
|
.buildAndExpand(this.realm)
|
||||||
|
.encode().toUriString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a keycloak OIDC client.
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public KeycloakIdentityManagerService(
|
||||||
|
@Qualifier("keycloakWebClient") final WebClient keycloakWebClient,
|
||||||
|
final KeycloakAdminReactiveClient keycloakAdminClient,
|
||||||
|
final KeycloakClientConfigProperties configProperties
|
||||||
|
) {
|
||||||
|
this.webClient = keycloakWebClient;
|
||||||
|
this.keycloakAdminClient = keycloakAdminClient;
|
||||||
|
this.keycloakServerUrl = configProperties.getKeycloakHost();
|
||||||
|
this.realm = configProperties.getRealm();
|
||||||
|
this.clientId = configProperties.getClientId();
|
||||||
|
this.clientSecret = configProperties.getClientSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<TokenResponse> login(String username, String password) {
|
||||||
|
log.debug("Attempting login for user: {}", username);
|
||||||
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||||
|
formData.add("client_id", this.clientId);
|
||||||
|
formData.add("client_secret", this.clientSecret);
|
||||||
|
formData.add("grant_type", "password"); // Use configured grant type (password)
|
||||||
|
formData.add("username", username);
|
||||||
|
formData.add("password", password);
|
||||||
|
formData.add("scope", "openid email profile"); // Request standard scopes
|
||||||
|
|
||||||
|
return this.webClient.post().uri(getTokenEndpoint())
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||||
|
// Handle specific HTTP status codes
|
||||||
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||||
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||||
|
.flatMap(errorBody -> {
|
||||||
|
log.error("Keycloak login failed with status {}: {}",
|
||||||
|
clientResponse.statusCode(), errorBody);
|
||||||
|
return Mono.error(new AuthenticationException(
|
||||||
|
"Login failed: Invalid credentials or Keycloak error."
|
||||||
|
+ " Status: "
|
||||||
|
+ clientResponse.statusCode()));
|
||||||
|
}))
|
||||||
|
.bodyToMono(TokenResponse.class)
|
||||||
|
.doOnSuccess(response -> log.debug("Login successful for user: {}", username))
|
||||||
|
.doOnError(error -> log.error("Error during login for user {}: {}", username,
|
||||||
|
error.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<VerificationResponse> verifyToken(String accessToken) {
|
||||||
|
log.debug("Verifying access token");
|
||||||
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||||
|
formData.add("client_id", this.clientId);
|
||||||
|
formData.add("client_secret", this.clientSecret);
|
||||||
|
formData.add("token", accessToken);
|
||||||
|
|
||||||
|
return this.webClient.post().uri(getIntrospectionEndpoint())
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||||
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||||
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||||
|
.flatMap(errorBody -> {
|
||||||
|
log.error(
|
||||||
|
"Keycloak token introspection failed with"
|
||||||
|
+ " status {}: {}",
|
||||||
|
clientResponse.statusCode(), errorBody);
|
||||||
|
return Mono.error(new TokenVerificationException(
|
||||||
|
"Token introspection failed. Status: "
|
||||||
|
+ clientResponse.statusCode()));
|
||||||
|
}))
|
||||||
|
.bodyToMono(VerificationResponse.class).flatMap(response -> {
|
||||||
|
if (!response.isActive()) {
|
||||||
|
log.warn("Token verification failed: Token is inactive.");
|
||||||
|
return Mono.error(
|
||||||
|
new TokenVerificationException("Token is invalid or expired."));
|
||||||
|
}
|
||||||
|
log.debug("Token verification successful. User: {}", response.getUsername());
|
||||||
|
return Mono.just(response);
|
||||||
|
}).doOnError(error -> log.error("Error during token verification: {}",
|
||||||
|
error.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> logout(String refreshToken) {
|
||||||
|
log.debug("Attempting logout (token revocation)");
|
||||||
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||||
|
formData.add("client_id", this.clientId);
|
||||||
|
formData.add("client_secret", this.clientSecret);
|
||||||
|
formData.add("token", refreshToken);
|
||||||
|
formData.add("token_type_hint", "refresh_token"); // Hint for Keycloak
|
||||||
|
|
||||||
|
return this.webClient.post().uri(getRevocationEndpoint())
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||||
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||||
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||||
|
.flatMap(errorBody -> {
|
||||||
|
// Keycloak might return 400 if token is already invalid, which
|
||||||
|
// is okay for logout
|
||||||
|
if (clientResponse.statusCode() == HttpStatus.BAD_REQUEST) {
|
||||||
|
log.warn(
|
||||||
|
"Token revocation returned 400 (possibly already"
|
||||||
|
+ " invalid/revoked): {}",
|
||||||
|
errorBody);
|
||||||
|
return Mono.empty(); // Treat as success in logout scenario
|
||||||
|
}
|
||||||
|
log.error("Keycloak token revocation failed with status {}: {}",
|
||||||
|
clientResponse.statusCode(), errorBody);
|
||||||
|
return Mono.error(new LogoutException("Logout failed. Status: "
|
||||||
|
+ clientResponse.statusCode()));
|
||||||
|
}))
|
||||||
|
.bodyToMono(Void.class) // Expecting empty body on success (2xx)
|
||||||
|
.doOnSuccess(v -> log.debug("Logout successful (token revoked)."))
|
||||||
|
.doOnError(error -> log.error("Error during logout: {}", error.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> registerUser(RegistrationRequest registrationRequest) {
|
||||||
|
return this.keycloakAdminClient.registerUser(registrationRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Boolean> checkResourcePermission(
|
||||||
|
final String reqAccessToken, final String clientId,
|
||||||
|
final String resourceUri, final String scope
|
||||||
|
) {
|
||||||
|
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
|
||||||
|
formData.add("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket");
|
||||||
|
formData.add("permission_resource_format", "uri");
|
||||||
|
formData.add("permission_resource_matching_uri", "true");
|
||||||
|
formData.add("audience", clientId);
|
||||||
|
formData.add("permission", resourceUri + "#" + scope);
|
||||||
|
|
||||||
|
return this.webClient.post().uri(getTokenEndpoint())
|
||||||
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
.header("Authorization", "Bearer " + reqAccessToken)
|
||||||
|
.body(BodyInserters.fromFormData(formData)).retrieve()
|
||||||
|
.onStatus(HttpStatusCode::is4xxClientError,
|
||||||
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||||
|
.flatMap(errBody -> {
|
||||||
|
log.debug("UMA ticket returns status {} with response: {}",
|
||||||
|
clientResponse.statusCode(), errBody);
|
||||||
|
return Mono.error(new PermissionCheckException(
|
||||||
|
"UMA ticket verification failed. Status: "
|
||||||
|
+ clientResponse.statusCode()));
|
||||||
|
}))
|
||||||
|
.onStatus(HttpStatusCode::is5xxServerError,
|
||||||
|
clientResponse -> clientResponse.bodyToMono(String.class)
|
||||||
|
.flatMap(errorBody -> {
|
||||||
|
log.error(
|
||||||
|
"Keycloak uma ticket verification failed with"
|
||||||
|
+ " status {}: {}",
|
||||||
|
clientResponse.statusCode(), errorBody);
|
||||||
|
return Mono.error(new ServiceUnavailableException(
|
||||||
|
"UMA ticket verification failed. Status: "
|
||||||
|
+ clientResponse.statusCode()));
|
||||||
|
}))
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.flatMap(response -> {
|
||||||
|
log.debug("UMA ticket verification successful for access token {}: {}",
|
||||||
|
reqAccessToken, response);
|
||||||
|
return Mono.just(true);
|
||||||
|
}).doOnError(error -> log.error("Error during uma ticket verification: {}",
|
||||||
|
error.getMessage()))
|
||||||
|
.onErrorResume(PermissionCheckException.class, e -> Mono.just(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
|
||||||
|
OrganizationRepresentation newOrg = new OrganizationRepresentation();
|
||||||
|
newOrg.setName(request.getName());
|
||||||
|
newOrg.setDescription(request.getDescription());
|
||||||
|
newOrg.setAttributes(request.getAttributes());
|
||||||
|
newOrg.setEnabled(true); // Default to enabled
|
||||||
|
|
||||||
|
if (request.getDomains() != null) {
|
||||||
|
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||||
|
.stream()
|
||||||
|
.map(domainName -> {
|
||||||
|
final var domain = new OrganizationDomainRepresentation(domainName);
|
||||||
|
domain.setVerified(false);
|
||||||
|
return domain;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
domainRepresentations.forEach(newOrg::addDomain);
|
||||||
|
}
|
||||||
|
return this.keycloakAdminClient.createOrganization(newOrg)
|
||||||
|
.map(this::toOrganizationResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<List<OrganizationResponse>> getOrganizations() {
|
||||||
|
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
|
||||||
|
.map(this::toOrganizationResponse).collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<OrganizationResponse> getOrganizationById(String orgId) {
|
||||||
|
return this.keycloakAdminClient.getOrganizationById(orgId)
|
||||||
|
.map(this::toOrganizationResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request) {
|
||||||
|
return this.keycloakAdminClient.getOrganizationById(orgId).flatMap(existingOrg -> {
|
||||||
|
// Update fields from the request
|
||||||
|
existingOrg.setDescription(request.getDescription());
|
||||||
|
existingOrg.setAttributes(request.getAttributes());
|
||||||
|
|
||||||
|
// Update domains based on the new list of names
|
||||||
|
if (request.getDomains() != null) {
|
||||||
|
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
|
||||||
|
.stream()
|
||||||
|
.map(domainName -> {
|
||||||
|
final var domain = new OrganizationDomainRepresentation(domainName);
|
||||||
|
domain.setVerified(false);
|
||||||
|
return domain;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
if (existingOrg.getDomains() != null) {
|
||||||
|
existingOrg.getDomains().clear();
|
||||||
|
}
|
||||||
|
domainRepresentations.forEach(existingOrg::addDomain);
|
||||||
|
}
|
||||||
|
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteOrganization(String orgId) {
|
||||||
|
return this.keycloakAdminClient.deleteOrganization(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request) {
|
||||||
|
KeycloakInvitationRequest invitation = new KeycloakInvitationRequest();
|
||||||
|
invitation.setEmail(request.getEmail());
|
||||||
|
invitation.setFirstName(request.getFirstName());
|
||||||
|
invitation.setLastName(request.getLastName());
|
||||||
|
// Keycloak handles creating the user if they don't exist.
|
||||||
|
return this.keycloakAdminClient.inviteUserToOrganization(orgId, invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId) {
|
||||||
|
return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList
|
||||||
|
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
|
||||||
|
return this.keycloakAdminClient.addMemberToOrganization(orgId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
|
||||||
|
return this.keycloakAdminClient.removeMemberFromOrganization(orgId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupResponse> createSubGroup(
|
||||||
|
final String parentGroupId, final CreateGroupRequest request
|
||||||
|
) {
|
||||||
|
GroupRepresentation newGroup = new GroupRepresentation();
|
||||||
|
newGroup.setName(request.getName());
|
||||||
|
newGroup.setAttributes(request.getAttributes());
|
||||||
|
|
||||||
|
return this.keycloakAdminClient.createSubGroup(parentGroupId, newGroup)
|
||||||
|
.thenReturn(new GroupResponse(null, request.getName(),
|
||||||
|
null, null, request.getAttributes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupResponse> getGroupById(final String groupId) {
|
||||||
|
return this.keycloakAdminClient.getGroupById(groupId).map(this::toGroupResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<List<GroupResponse>> listSubGroups(final String parentGroupId) {
|
||||||
|
return this.keycloakAdminClient.listSubGroups(parentGroupId)
|
||||||
|
.map(groupList -> groupList.stream()
|
||||||
|
.map(this::toGroupResponse)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
|
||||||
|
final var groupUpdate = new GroupRepresentation();
|
||||||
|
groupUpdate.setAttributes(request.getAttributes());
|
||||||
|
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteGroup(final String groupId) {
|
||||||
|
return this.keycloakAdminClient.deleteGroup(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> addMemberToGroup(final String groupId, final String userId) {
|
||||||
|
return this.keycloakAdminClient.addMemberToGroup(groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<List<OrganizationMemberResponse>> getGroupMembers(final String groupId) {
|
||||||
|
return this.keycloakAdminClient.getGroupMembers(groupId)
|
||||||
|
.map(userList -> userList.stream()
|
||||||
|
.map(this::toOrganizationMemberResponse)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> removeMemberFromGroup(final String groupId, final String userId) {
|
||||||
|
return this.keycloakAdminClient.removeMemberFromGroup(groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add DTO Mapper for Group ---
|
||||||
|
private GroupResponse toGroupResponse(final GroupRepresentation keycloakGroup) {
|
||||||
|
if (keycloakGroup == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new GroupResponse(
|
||||||
|
keycloakGroup.getId(),
|
||||||
|
keycloakGroup.getName(),
|
||||||
|
keycloakGroup.getPath(),
|
||||||
|
keycloakGroup.getDescription(),
|
||||||
|
keycloakGroup.getAttributes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DTO Mapper Helper Methods ---
|
||||||
|
|
||||||
|
private OrganizationResponse toOrganizationResponse(
|
||||||
|
OrganizationRepresentation keycloakOrg) {
|
||||||
|
if (keycloakOrg == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
|
||||||
|
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.isEnabled(),
|
||||||
|
keycloakOrg.getAttributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrganizationMemberResponse toOrganizationMemberResponse(
|
||||||
|
UserRepresentation keycloakUser) {
|
||||||
|
if (keycloakUser == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(),
|
||||||
|
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
|
||||||
|
keycloakUser.isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Spring Boot application configuration
|
||||||
|
server:
|
||||||
|
port: ${AUTH_SERVICE_PORT:8099} # Port the auth-service will run on
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: auth-service
|
||||||
|
cloud:
|
||||||
|
consul:
|
||||||
|
host: ${CONSUL_HOST:consul}
|
||||||
|
port: ${CONSUL_PORT:8500}
|
||||||
|
config:
|
||||||
|
import-check:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Keycloak Configuration (adjust values as needed)
|
||||||
|
keycloak:
|
||||||
|
keycloak-host: ${KEYCLOAK_AUTH_SERVER_URL:http://keycloak.dev.localhost:8000} # Base URL of your Keycloak instance (NO trailing slash)
|
||||||
|
keycloak-admin-host: ${KEYCLOAK_AUTH_ADMIN_SERVER_URL:http://keycloak.dev.localhost:8000} # Base URL of your Keycloak instance (NO trailing slash)
|
||||||
|
realm: ${KEYCLOAK_AUTH_REALM:clevermicro-dev} # The realm name you are using
|
||||||
|
client-id: ${KEYCLOAK_AUTH_SERVICE_CLIENT:auth-service} # Client ID created in Keycloak for this service
|
||||||
|
client-secret: ${KEYCLOAK_AUTH_SERVICE_SECRET:UJ1sMcWciIRREfjjAGoLHUDynLT4aYdD} # Client Secret from Keycloak (use secrets management!)
|
||||||
|
admin-account-username: ${KEYCLOAK_AUTH_ADMIN_CLIENT:auth-service-account} # an admin account for admin operations
|
||||||
|
admin-account-password: ${KEYCLOAK_AUTH_ADMIN_SECRET:6B8KWFQDPOoj88V7xnMYoQIgj9vVfuvw} # Secret for this admin account
|
||||||
|
auth-service:
|
||||||
|
permission-mapping:
|
||||||
|
# Rules to map incoming request path prefixes to Keycloak Client IDs (representing services)
|
||||||
|
# The Keycloak Client ID is the one you set in Keycloak (e.g., "cleverswarm-app")
|
||||||
|
# The 'pathPrefix' will be matched against the X-Forwarded-Uri
|
||||||
|
rules:
|
||||||
|
- pathPrefix: "/auth-service/" # If X-Forwarded-Uri starts with /cleverswarm/
|
||||||
|
keycloakClientId: "auth-service" # ...then permissions are checked against this Keycloak client
|
||||||
|
# Add more rules as needed
|
||||||
|
# Default Keycloak Client ID if no prefix matches (optional)
|
||||||
|
# defaultKeycloakClientId: "general-api-client"
|
||||||
|
|
||||||
|
# CleverMicro client
|
||||||
|
clevermicro:
|
||||||
|
client:
|
||||||
|
# rabbitmq
|
||||||
|
rabbitmq-host: ${RABBITMQ_HOST:rabbitmq}
|
||||||
|
rabbitmq-port: ${RABBITMQ_PORT:5672}
|
||||||
|
rabbitmq-username: ${RABBITMQ_USER:springuser}
|
||||||
|
rabbitmq-password: ${RABBITMQ_PASSWORD:TheCleverWho}
|
||||||
|
rabbitmq-vhost: ${RABBITMQ_VHOST:/}
|
||||||
|
# swarm env
|
||||||
|
swarm-service-id: ${SWARM_SERVICE_ID:auth-service-id}
|
||||||
|
swarm-task-id=: ${SWARM_TASK_ID:dummy-task-id}
|
||||||
|
# backpressure
|
||||||
|
initial-availability: ${MAX_AVAILABILITY:-1}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
# Set log levels as needed.
|
||||||
|
com.clevermicro.authservice: DEBUG
|
||||||
|
org.springframework.web.client.RestTemplate: DEBUG
|
||||||
|
reactor.netty.http.client: DEBUG
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package com.cleverthis.authservice;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
|
import com.cleverthis.authservice.controller.AuthController;
|
||||||
|
import com.cleverthis.authservice.dto.LoginRequest;
|
||||||
|
import com.cleverthis.authservice.dto.LogoutRequest;
|
||||||
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||||
|
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||||
|
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||||
|
import com.cleverthis.authservice.exception.LogoutException;
|
||||||
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import com.cleverthis.authservice.service.impl.FallbackPermissionMapLookupService;
|
||||||
|
import com.ecwid.consul.v1.ConsulClient;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the AuthController using WebFluxTest and Mockito. Focuses on testing the
|
||||||
|
* controller logic and request/response handling.
|
||||||
|
*/
|
||||||
|
@WebFluxTest(AuthController.class)
|
||||||
|
@Import({GlobalExceptionHandler.class,
|
||||||
|
PermissionMappingConfigProperties.class,
|
||||||
|
FallbackPermissionMapLookupService.class})
|
||||||
|
class AuthControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private PermissionMappingConfigProperties permissionMappingConfigProperties;
|
||||||
|
|
||||||
|
// required by the fallback permission mapping lookup service
|
||||||
|
// provide a mocked one
|
||||||
|
@MockitoBean
|
||||||
|
private ConsulClient consulClient;
|
||||||
|
|
||||||
|
private TokenResponse mockTokenResponse;
|
||||||
|
private VerificationResponse mockUserVerificationResponse;
|
||||||
|
|
||||||
|
// --- Constants for Header Names (mirroring controller) ---
|
||||||
|
private static final String HEADER_USER_ID = "X-User-Id";
|
||||||
|
private static final String HEADER_USER_NAME = "X-User-Name";
|
||||||
|
private static final String HEADER_USER_EMAIL = "X-User-Email";
|
||||||
|
private static final String HEADER_USER_GROUPS = "X-User-Groups";
|
||||||
|
|
||||||
|
// --- Traefik Forwarded Headers ---
|
||||||
|
private static final String HEADER_X_FORWARDED_METHOD = "X-Forwarded-Method";
|
||||||
|
private static final String HEADER_X_FORWARDED_URI = "X-Forwarded-Uri";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer",
|
||||||
|
"id-789", 0, "state", "openid");
|
||||||
|
this.mockUserVerificationResponse =
|
||||||
|
new VerificationResponse(true, "test-client", "testuser", "openid", "user-sub-123",
|
||||||
|
System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000,
|
||||||
|
System.currentTimeMillis() / 1000, List.of("aud1"), "iss", "Bearer",
|
||||||
|
"test@example.com", true, "testuser", List.of("groupA"));
|
||||||
|
|
||||||
|
// Setup mock PermissionMappingConfig - Default to having some rules
|
||||||
|
PermissionMappingConfigProperties.Rule rule1 = new PermissionMappingConfigProperties.Rule();
|
||||||
|
rule1.setPathPrefix("/serviceA/");
|
||||||
|
rule1.setKeycloakClientId("serviceA-client");
|
||||||
|
|
||||||
|
PermissionMappingConfigProperties.Rule rule2 = new PermissionMappingConfigProperties.Rule();
|
||||||
|
rule2.setPathPrefix("/serviceB/items/");
|
||||||
|
rule2.setKeycloakClientId("serviceB-client");
|
||||||
|
|
||||||
|
List<PermissionMappingConfigProperties.Rule> rules = new ArrayList<>();
|
||||||
|
rules.add(rule1);
|
||||||
|
rules.add(rule2);
|
||||||
|
// Mock getRules() to return the list
|
||||||
|
when(this.permissionMappingConfigProperties.getRules()).thenReturn(rules);
|
||||||
|
// Mock getDefaultKeycloakClientId() to return null by default, can be overridden in
|
||||||
|
// specific tests
|
||||||
|
when(this.permissionMappingConfigProperties.getDefaultKeycloakClientId()).thenReturn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /login Tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_Success_ReturnsTokenResponse() {
|
||||||
|
LoginRequest loginRequest = new LoginRequest("testuser", "password");
|
||||||
|
when(this.identityManagerService.login("testuser", "password"))
|
||||||
|
.thenReturn(Mono.just(this.mockTokenResponse));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(loginRequest).exchange().expectStatus().isOk() // Expect 200
|
||||||
|
.expectBody(TokenResponse.class)
|
||||||
|
.value(TokenResponse::getAccessToken, equalTo("access-123"))
|
||||||
|
.value(TokenResponse::getRefreshToken, equalTo("refresh-456"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_ServiceThrowsAuthException_ReturnsUnauthorized() {
|
||||||
|
LoginRequest loginRequest = new LoginRequest("wronguser", "wrongpass");
|
||||||
|
// Mock the service to throw the specific exception handled by GlobalExceptionHandler
|
||||||
|
when(this.identityManagerService.login("wronguser", "wrongpass"))
|
||||||
|
.thenReturn(Mono.error(new AuthenticationException("Invalid credentials")));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(loginRequest).exchange().expectStatus().isUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_InvalidRequest_ReturnsBadRequest() {
|
||||||
|
LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request
|
||||||
|
|
||||||
|
// No need to mock service for validation errors
|
||||||
|
// The framework and GlobalExceptionHandler (via parent) should handle this
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(loginRequest).exchange().expectStatus().isBadRequest(); // Expect 400
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /verify Tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verify_Success_ReturnsVerificationResponse() {
|
||||||
|
String validToken = "valid-token-123";
|
||||||
|
when(this.identityManagerService.verifyToken(validToken))
|
||||||
|
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/verify")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus()
|
||||||
|
.isOk() // Expect 200
|
||||||
|
.expectBody(VerificationResponse.class)
|
||||||
|
.value(VerificationResponse::isActive, equalTo(true))
|
||||||
|
.value(VerificationResponse::getUsername, equalTo("testuser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verify_ServiceThrowsVerificationException_ReturnsUnauthorized() {
|
||||||
|
String invalidToken = "invalid-token-456";
|
||||||
|
// Mock the service to throw the specific exception
|
||||||
|
when(this.identityManagerService.verifyToken(invalidToken))
|
||||||
|
.thenReturn(Mono.error(new TokenVerificationException("Token expired")));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/verify")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange()
|
||||||
|
.expectStatus().isUnauthorized(); // Expect 401 (handled by GlobalExceptionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verify_MissingAuthHeader_ReturnsBadRequest() { // Renamed test slightly for clarity
|
||||||
|
// No service mocking needed, framework handles missing required header
|
||||||
|
this.webTestClient.post().uri("/verify").exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
|
||||||
|
// Controller logic handles this case specifically
|
||||||
|
this.webTestClient.post().uri("/verify")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme
|
||||||
|
.exchange().expectStatus().isUnauthorized(); // Expect 401 (handled by controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /auth (ForwardAuth) Tests ---
|
||||||
|
|
||||||
|
// --- NEW /auth (ForwardAuth) Tests with Candidate Role List Logic ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_PermissionGranted_ReturnsOkWithUserHeaders() {
|
||||||
|
String userToken = "user-token-granted";
|
||||||
|
String originalMethod = "GET";
|
||||||
|
String originalUri = "/serviceA/api/data"; // Matches rule1
|
||||||
|
|
||||||
|
when(this.identityManagerService.verifyToken(userToken))
|
||||||
|
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||||
|
when(this.identityManagerService.checkResourcePermission(
|
||||||
|
userToken, "serviceA-client", "/api/data", "rest:read"
|
||||||
|
)).thenReturn(Mono.just(true));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||||
|
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isOk()
|
||||||
|
.expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
|
||||||
|
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
|
||||||
|
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||||
|
.valueEquals(HEADER_USER_GROUPS, "groupA").expectBody().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_Success_ReturnsOkWithHeaders_NoGroupsInToken() { // Renamed for clarity
|
||||||
|
String validToken = "valid-token-fw-nogroups";
|
||||||
|
VerificationResponse responseWithoutGroups = new VerificationResponse(true, "test-client",
|
||||||
|
"testuser", "openid", "user-sub-123", System.currentTimeMillis() / 1000 + 300,
|
||||||
|
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
|
||||||
|
List.of("aud1"), "iss", "Bearer", "test@example.com", true, "testuser", null);
|
||||||
|
|
||||||
|
when(this.identityManagerService.verifyToken(validToken))
|
||||||
|
.thenReturn(Mono.just(responseWithoutGroups));
|
||||||
|
when(this.identityManagerService.checkResourcePermission(
|
||||||
|
validToken, "serviceA-client",
|
||||||
|
"/api/nogroups", "rest:read"
|
||||||
|
)).thenReturn(Mono.just(true));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||||
|
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/nogroups").exchange().expectStatus()
|
||||||
|
.isOk().expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader()
|
||||||
|
.valueEquals(HEADER_USER_NAME, "testuser").expectHeader()
|
||||||
|
.valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader()
|
||||||
|
.doesNotExist(HEADER_USER_GROUPS).expectBody().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_PermissionDenied_NoMatchingRole() {
|
||||||
|
String userToken = "user-token-denied";
|
||||||
|
String originalMethod = "POST";
|
||||||
|
String originalUri = "/serviceA/restricted/action"; // -> /restricted/action
|
||||||
|
|
||||||
|
when(this.identityManagerService.verifyToken(userToken))
|
||||||
|
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||||
|
when(this.identityManagerService.checkResourcePermission(
|
||||||
|
userToken, "serviceA-client",
|
||||||
|
"/restricted/action", "rest:create"
|
||||||
|
)).thenReturn(Mono.just(false));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||||
|
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus().isForbidden()
|
||||||
|
.expectBody().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_TokenInvalid_ReturnsUnauthorized() {
|
||||||
|
String invalidUserToken = "invalid-user-token";
|
||||||
|
String originalMethod = "GET";
|
||||||
|
String originalUri = "/serviceA/api/data";
|
||||||
|
|
||||||
|
when(this.identityManagerService.verifyToken(invalidUserToken))
|
||||||
|
.thenReturn(Mono.error(new TokenVerificationException("Token is expired")));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidUserToken)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||||
|
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||||
|
.isUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_ServiceThrowsGenericExceptionDuringVerification_ReturnsUnauthorized() {
|
||||||
|
String token = "token-causing-error";
|
||||||
|
String originalMethod = "GET";
|
||||||
|
String originalUri = "/serviceA/api/data";
|
||||||
|
|
||||||
|
when(this.identityManagerService.verifyToken(token)).thenReturn(
|
||||||
|
Mono.error(new RuntimeException("Internal service error during verification")));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth").header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||||
|
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||||
|
.isUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_NoMatchingPathRule_ReturnsForbidden() {
|
||||||
|
String userToken = "user-token-no-rule";
|
||||||
|
String originalMethod = "GET";
|
||||||
|
String originalUri = "/unknownService/api/data"; // No rule matches this prefix
|
||||||
|
|
||||||
|
// Mock getRules to return an empty list for this specific test case, or ensure default
|
||||||
|
// setup handles it
|
||||||
|
// For this test, let's ensure the default setup from setUp() (which has rules for
|
||||||
|
// /serviceA/)
|
||||||
|
// does not match "/unknownService/"
|
||||||
|
when(this.identityManagerService.verifyToken(userToken))
|
||||||
|
.thenReturn(Mono.just(this.mockUserVerificationResponse));
|
||||||
|
// No call to checkUserClientRolePermission should happen if no rule matches
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userToken)
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, originalMethod)
|
||||||
|
.header(HEADER_X_FORWARDED_URI, originalUri).exchange().expectStatus()
|
||||||
|
.isForbidden(); // Controller logic for no rule match
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_MissingAuthHeader_ReturnsBadRequest() {
|
||||||
|
// This tests if @RequestHeader(HttpHeaders.AUTHORIZATION) is enforced
|
||||||
|
this.webTestClient.post().uri("/auth").header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||||
|
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
|
||||||
|
// Missing Authorization header
|
||||||
|
.exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_InvalidAuthHeaderFormat_ReturnsUnauthorized() {
|
||||||
|
// This tests the controller's specific check for "Bearer " prefix
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Basic somecredentials")
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||||
|
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data").exchange().expectStatus()
|
||||||
|
.isUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_MissingXforwardedMethodHeader_ReturnsBadRequest() {
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
|
||||||
|
.header(HEADER_X_FORWARDED_URI, "/serviceA/api/data")
|
||||||
|
// Missing X-Forwarded-Method
|
||||||
|
.exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardAuth_MissingXforwardedUriHeader_ReturnsBadRequest() {
|
||||||
|
this.webTestClient.post().uri("/auth")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer some-token")
|
||||||
|
.header(HEADER_X_FORWARDED_METHOD, "GET")
|
||||||
|
// Missing X-Forwarded-Uri
|
||||||
|
.exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /logout Tests ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_Success_ReturnsNoContent() {
|
||||||
|
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke");
|
||||||
|
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_ServiceThrowsException_ReturnsInternalServerError() {
|
||||||
|
LogoutRequest logoutRequest = new LogoutRequest("refresh-token-error");
|
||||||
|
// Mock service to throw the specific exception
|
||||||
|
when(this.identityManagerService.logout(logoutRequest.getRefreshToken()))
|
||||||
|
.thenReturn(Mono.error(new LogoutException("Keycloak unavailable")));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_InvalidRequest_ReturnsBadRequest() {
|
||||||
|
LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token)
|
||||||
|
|
||||||
|
// No service mocking needed
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.cleverthis.authservice;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.controller.IntraOrganizationGroupController;
|
||||||
|
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.group.GroupResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||||
|
import com.cleverthis.authservice.exception.ResourceNotFoundException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the IntraOrganizationGroupController. This class tests the API
|
||||||
|
* layer for sub-group management, mocking the service layer.
|
||||||
|
*/
|
||||||
|
@WebFluxTest(IntraOrganizationGroupController.class)
|
||||||
|
@Import(GlobalExceptionHandler.class)
|
||||||
|
public class IntraOrganizationGroupControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
private GroupResponse mockGroupResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up mock data before each test.
|
||||||
|
*/
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockGroupResponse = new GroupResponse("group-id-eng", "engineering",
|
||||||
|
"/tenant-id-123/engineering",
|
||||||
|
"Engineering Department", Map.of("cost-center", List.of("ENG-101")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful creation of a sub-group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createSubGroup_Success_ReturnsCreated() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
CreateGroupRequest request = new CreateGroupRequest("engineering",
|
||||||
|
"Engineering Department", null);
|
||||||
|
|
||||||
|
when(identityManagerService.createSubGroup(eq(parentGroupId), any(CreateGroupRequest.class)))
|
||||||
|
.thenReturn(Mono.just(mockGroupResponse));
|
||||||
|
|
||||||
|
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
|
||||||
|
.exchange().expectStatus().isCreated()
|
||||||
|
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests creation of a sub-group with invalid input.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createSubGroup_InvalidInput_ReturnsBadRequest() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
// Blank name is invalid
|
||||||
|
CreateGroupRequest request = new CreateGroupRequest("", null, null);
|
||||||
|
|
||||||
|
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||||
|
.expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful listing of sub-groups.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listSubGroups_Success_ReturnsListOfGroups() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
when(identityManagerService.listSubGroups(parentGroupId))
|
||||||
|
.thenReturn(Mono.just(Collections.singletonList(mockGroupResponse)));
|
||||||
|
|
||||||
|
webTestClient.get().uri("/groups/{parentGroupId}/children", parentGroupId).exchange()
|
||||||
|
.expectStatus().isOk()
|
||||||
|
.expectBodyList(GroupResponse.class).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful retrieval of a group by its ID.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getGroupById_Exists_ReturnsGroup() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
when(identityManagerService.getGroupById(groupId)).thenReturn(Mono.just(mockGroupResponse));
|
||||||
|
|
||||||
|
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}", parentGroupId, groupId)
|
||||||
|
.exchange().expectStatus().isOk()
|
||||||
|
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests retrieval of a non-existent group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getGroupById_NotFound_ReturnsNotFound() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "non-existent-group";
|
||||||
|
when(identityManagerService.getGroupById(groupId))
|
||||||
|
.thenReturn(Mono.error(new ResourceNotFoundException("Group not found")));
|
||||||
|
|
||||||
|
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||||
|
parentGroupId, groupId).exchange()
|
||||||
|
.expectStatus().isNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful update of a group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void updateGroup_Success_ReturnsNoContent() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
UpdateGroupRequest request = new UpdateGroupRequest("An updated description", null);
|
||||||
|
|
||||||
|
when(identityManagerService.updateGroup(eq(groupId), any(UpdateGroupRequest.class)))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
webTestClient.put().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||||
|
parentGroupId, groupId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
|
||||||
|
.exchange().expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful deletion of a group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deleteGroup_Success_ReturnsNoContent() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
when(identityManagerService.deleteGroup(groupId)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
webTestClient.delete().uri("/groups/{parentGroupId}/children/{groupId}",
|
||||||
|
parentGroupId, groupId).exchange()
|
||||||
|
.expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successfully adding a member to a group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void addMemberToGroup_Success_ReturnsNoContent() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
AddGroupMemberRequest request = new AddGroupMemberRequest("user-id-to-add");
|
||||||
|
when(identityManagerService.addMemberToGroup(groupId, request.getUserId())).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
webTestClient.post().uri("/groups/{parentGroupId}/children/{groupId}/members",
|
||||||
|
parentGroupId, groupId).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful listing of group members.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void listGroupMembers_Success_ReturnsListOfMembers() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
List<OrganizationMemberResponse> members = List.of(
|
||||||
|
new OrganizationMemberResponse("user-id-123", "abed.spring",
|
||||||
|
"Abed", "Spring", "abed@test.com", true));
|
||||||
|
when(identityManagerService.getGroupMembers(groupId)).thenReturn(Mono.just(members));
|
||||||
|
|
||||||
|
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}/members",
|
||||||
|
parentGroupId, groupId).exchange()
|
||||||
|
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class).isEqualTo(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successfully removing a member from a group.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void removeMemberFromGroup_Success_ReturnsNoContent() {
|
||||||
|
String parentGroupId = "org-id-123";
|
||||||
|
String groupId = "group-id-eng";
|
||||||
|
String userId = "user-to-remove";
|
||||||
|
when(identityManagerService.removeMemberFromGroup(groupId, userId)).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
webTestClient.delete()
|
||||||
|
.uri("/groups/{parentGroupId}/children/{groupId}/members/{userId}",
|
||||||
|
parentGroupId, groupId, userId)
|
||||||
|
.exchange().expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.cleverthis.authservice;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.controller.OrganizationController;
|
||||||
|
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||||
|
import com.cleverthis.authservice.exception.ResourceNotFoundException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the OrganizationController. This class tests the API layer for organization
|
||||||
|
* management, mocking the service layer.
|
||||||
|
*/
|
||||||
|
@WebFluxTest(OrganizationController.class)
|
||||||
|
@Import(GlobalExceptionHandler.class)
|
||||||
|
public class OrganizationControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
private OrganizationResponse mockOrgResponse;
|
||||||
|
private List<OrganizationResponse> mockOrgResponseList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up mock data before each test.
|
||||||
|
*/
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// Create the correct domain representation object
|
||||||
|
final var domain = new OrganizationDomainRepresentation("test-org.com");
|
||||||
|
domain.setVerified(true);
|
||||||
|
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
|
||||||
|
"A test organization", true, Map.of("displayName", List.of("Test Org")));
|
||||||
|
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful creation of an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createOrganization_Success_ReturnsCreatedWithBody() {
|
||||||
|
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
|
||||||
|
List.of("test-org.com"), "A test org", null);
|
||||||
|
|
||||||
|
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
|
||||||
|
.thenReturn(Mono.just(this.mockOrgResponse));
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isCreated()
|
||||||
|
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests organization creation with invalid input, expecting a Bad Request.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createOrganization_InvalidInput_ReturnsBadRequest() {
|
||||||
|
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful retrieval of all organizations.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getOrganizations_Success_ReturnsListOfOrgs() {
|
||||||
|
when(this.identityManagerService.getOrganizations())
|
||||||
|
.thenReturn(Mono.just(this.mockOrgResponseList));
|
||||||
|
|
||||||
|
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
|
||||||
|
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful retrieval of a single organization by its ID.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getOrganizationById_Exists_ReturnsOrg() {
|
||||||
|
when(this.identityManagerService.getOrganizationById("org-id-123"))
|
||||||
|
.thenReturn(Mono.just(this.mockOrgResponse));
|
||||||
|
|
||||||
|
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
|
||||||
|
.expectStatus().isOk().expectBody(OrganizationResponse.class)
|
||||||
|
.isEqualTo(this.mockOrgResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests retrieval of a non-existent organization, expecting Not Found.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getOrganizationById_NotFound_ReturnsNotFound() {
|
||||||
|
when(this.identityManagerService.getOrganizationById("non-existent-id"))
|
||||||
|
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
|
||||||
|
|
||||||
|
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
|
||||||
|
.expectStatus().isNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful update of an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void updateOrganization_Success_ReturnsNoContent() {
|
||||||
|
UpdateOrganizationRequest request =
|
||||||
|
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
|
||||||
|
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
|
||||||
|
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||||
|
.expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful deletion of an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deleteOrganization_Success_ReturnsNoContent() {
|
||||||
|
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
|
||||||
|
.expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful invitation of a user to an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void inviteUserToOrganization_Success_ReturnsOk() {
|
||||||
|
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
|
||||||
|
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
|
||||||
|
any(InviteUserRequest.class))).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||||
|
.expectStatus().isOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successful retrieval of organization members.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void getOrganizationMembers_Success_ReturnsListOfMembers() {
|
||||||
|
List<OrganizationMemberResponse> members =
|
||||||
|
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
|
||||||
|
"test@test.com", true));
|
||||||
|
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
|
||||||
|
.thenReturn(Mono.just(members));
|
||||||
|
|
||||||
|
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
|
||||||
|
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
|
||||||
|
.isEqualTo(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successfully adding a member to an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void addMemberToOrganization_Success_ReturnsNoContent() {
|
||||||
|
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
|
||||||
|
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
|
||||||
|
.expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successfully removing a member from an organization.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void removeMemberFromOrganization_Success_ReturnsNoContent() {
|
||||||
|
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
|
||||||
|
"user-to-remove-id")).thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
this.webTestClient.delete()
|
||||||
|
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
|
||||||
|
.exchange().expectStatus().isNoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.cleverthis.authservice;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.controller.UserRegistrationController;
|
||||||
|
import com.cleverthis.authservice.dto.RegistrationRequest;
|
||||||
|
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
|
||||||
|
import com.cleverthis.authservice.exception.RegistrationException;
|
||||||
|
import com.cleverthis.authservice.exception.UserAlreadyExistsException;
|
||||||
|
import com.cleverthis.authservice.service.IdentityManagerService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test class for /register in UserRegistrationController.
|
||||||
|
*/
|
||||||
|
@WebFluxTest(UserRegistrationController.class)
|
||||||
|
@Import(GlobalExceptionHandler.class)
|
||||||
|
public class UserRegistrationControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WebTestClient webTestClient;
|
||||||
|
|
||||||
|
@MockitoBean // Mocks the IdentityManagerService
|
||||||
|
private IdentityManagerService identityManagerService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_Success_ReturnsCreated() {
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest("Test", "User", "newuser@example.com", "Password123!");
|
||||||
|
|
||||||
|
// Mock the service layer to complete successfully
|
||||||
|
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
|
||||||
|
.thenReturn(Mono.empty()); // registerUser returns Mono<Void>
|
||||||
|
// Expect 201 CREATED
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_EmailAlreadyExists_ReturnsConflict() {
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest("Test", "User", "existinguser@example.com", "Password123!");
|
||||||
|
|
||||||
|
// Mock the service layer to throw UserAlreadyExistsException
|
||||||
|
when(this.identityManagerService.registerUser(any(RegistrationRequest.class)))
|
||||||
|
.thenReturn(Mono.error(new UserAlreadyExistsException(
|
||||||
|
"User with email existinguser@example.com already exists.")));
|
||||||
|
// Expect 409 CONFLICT (handled by GlobalExceptionHandler)
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().is5xxServerError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_InvalidEmailFormat_ReturnsBadRequest() {
|
||||||
|
// Invalid email
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest("Test", "User", "invalidemail", "Password123!");
|
||||||
|
|
||||||
|
// No service mocking needed as @Valid annotation should trigger validation error
|
||||||
|
// Expect 400 BAD_REQUEST
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_PasswordTooShort_ReturnsBadRequest() {
|
||||||
|
// Password too short
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest("Test", "User", "user@example.com", "pass");
|
||||||
|
// Expect 400 BAD_REQUEST
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_MissingFirstName_ReturnsBadRequest() {
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest(null, "User", "user@example.com", "Password123!");
|
||||||
|
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().isBadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerUser_GenericRegistrationFailure_ReturnsInternalServerError() {
|
||||||
|
RegistrationRequest request =
|
||||||
|
new RegistrationRequest("Test", "User", "erroruser@example.com", "Password123!");
|
||||||
|
|
||||||
|
// Mock the service layer to throw a generic RegistrationException
|
||||||
|
when(this.identityManagerService.registerUser(any(RegistrationRequest.class))).thenReturn(
|
||||||
|
Mono.error(new RegistrationException("A generic Keycloak error occurred.")));
|
||||||
|
// Expect 500 (handled by GlobalExceptionHandler for RegistrationException)
|
||||||
|
this.webTestClient.post().uri("/users/register").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(request).exchange().expectStatus().is5xxServerError();
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.test.context.ContextConfiguration;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||||
|
|
||||||
|
@ExtendWith(SpringExtension.class)
|
||||||
|
@ContextConfiguration(classes = {KeycloakClientConfigPropertiesTest.class})
|
||||||
|
@EnableConfigurationProperties({KeycloakClientConfigProperties.class})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"keycloak.keycloak-host=https://keycloak.example.com",
|
||||||
|
"keycloak.keycloak-admin-host=https://keycloak-admin.example.com",
|
||||||
|
"keycloak.realm=cm-test",
|
||||||
|
"keycloak.client-id=test-service",
|
||||||
|
"keycloak.client-secret=secret-here",
|
||||||
|
"keycloak.admin-account-username=admin",
|
||||||
|
"keycloak.admin-account-password=admin-passwd"
|
||||||
|
})
|
||||||
|
class KeycloakClientConfigPropertiesTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KeycloakClientConfigProperties config;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testConfigParsing() {
|
||||||
|
assertEquals("https://keycloak.example.com", this.config.getKeycloakHost());
|
||||||
|
assertEquals("https://keycloak-admin.example.com", this.config.getKeycloakAdminHost());
|
||||||
|
assertEquals("cm-test", this.config.getRealm());
|
||||||
|
assertEquals("test-service", this.config.getClientId());
|
||||||
|
assertEquals("secret-here", this.config.getClientSecret());
|
||||||
|
assertEquals("admin", this.config.getAdminAccountUsername());
|
||||||
|
assertEquals("admin-passwd", this.config.getAdminAccountPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class KeycloakClientConfigTest {
|
||||||
|
@Test
|
||||||
|
void testConstructor() {
|
||||||
|
assertDoesNotThrow(KeycloakClientConfig::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.cleverthis.authservice.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
public class WebClientConfigTest {
|
||||||
|
|
||||||
|
private final WebClientConfig config = new WebClientConfig();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testKeycloakWebClientNotNull() {
|
||||||
|
assertNotNull(this.config.keycloakWebClient(), "WebClient bean should not be null");
|
||||||
|
}
|
||||||
|
}
|
||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.cleverthis.authservice.exception;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
class GlobalExceptionHandlerTest {
|
||||||
|
|
||||||
|
private GlobalExceptionHandler globalExceptionHandler;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
this.globalExceptionHandler = new GlobalExceptionHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleAuthenticationException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "Authentication failed";
|
||||||
|
final var exception = new AuthenticationException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleAuthenticationException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("Authentication Failed", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleTokenVerificationException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "Token verification failed";
|
||||||
|
final var exception = new TokenVerificationException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleTokenVerificationException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("Token Verification Failed", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleLogoutException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "Logout failed";
|
||||||
|
final var exception = new LogoutException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleLogoutException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("Logout Failed", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandlePermissionCheckException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "Access denied";
|
||||||
|
final var exception = new PermissionCheckException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handlePermissionCheckException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.FORBIDDEN.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("Access Denied", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleServiceUnavailableException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var exception = new ServiceUnavailableException("test");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleServiceUnavailableException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.SERVICE_UNAVAILABLE.value(), result.getStatus());
|
||||||
|
assertEquals("An external service required for authorization is currently unavailable.",
|
||||||
|
result.getDetail());
|
||||||
|
assertEquals("Service Unavailable", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleUserAlreadyExistsException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "User already exists";
|
||||||
|
final var exception = new UserAlreadyExistsException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleUserAlreadyExistsException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.CONFLICT.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("User Registration Conflict", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleRegistrationException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var errorMessage = "Registration failed";
|
||||||
|
final var exception = new RegistrationException(errorMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleRegistrationException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
|
||||||
|
assertEquals(errorMessage, result.getDetail());
|
||||||
|
assertEquals("User Registration Failed", result.getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testHandleGenericException_ReturnsProblemDetail() {
|
||||||
|
// Arrange
|
||||||
|
final var exception = new Exception("Internal error");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.globalExceptionHandler.handleGenericException(exception);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getStatus());
|
||||||
|
assertEquals("An internal error occurred.", result.getDetail());
|
||||||
|
assertEquals("Internal Server Error", result.getTitle());
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.cleverthis.authservice.service.impl;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.PermissionMappingConfigProperties;
|
||||||
|
import com.ecwid.consul.v1.ConsulClient;
|
||||||
|
import com.ecwid.consul.v1.Response;
|
||||||
|
import com.ecwid.consul.v1.kv.model.GetValue;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
class FallbackPermissionMapLookupServiceTest {
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final ConsulClient consulClient = Mockito.mock(ConsulClient.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test {@link FallbackPermissionMapLookupService#refreshConsul()}}
|
||||||
|
* and {@link FallbackPermissionMapLookupService#getConfig()}
|
||||||
|
* to see if the refresh and fallback is working.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void testRefreshConsulAndGetConfigFallBack() throws JsonProcessingException {
|
||||||
|
final var service = new FallbackPermissionMapLookupService(
|
||||||
|
null, this.consulClient, this.objectMapper);
|
||||||
|
Mockito.reset(this.consulClient);
|
||||||
|
// null response from consul client
|
||||||
|
service.refreshConsul();
|
||||||
|
Mockito.verify(this.consulClient)
|
||||||
|
.getKVValue(FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY);
|
||||||
|
assertNull(service.getConfig()); // fallback is null
|
||||||
|
// have response but null value
|
||||||
|
final var resp = Mockito.mock(Response.class);
|
||||||
|
when(this.consulClient.getKVValue(
|
||||||
|
FallbackPermissionMapLookupService.PERMISSION_MAPPING_CONSUL_KEY))
|
||||||
|
.thenReturn(resp);
|
||||||
|
service.refreshConsul();
|
||||||
|
Mockito.verify(resp).getValue();
|
||||||
|
assertNull(service.getConfig()); // fallback is null
|
||||||
|
// has value but null content
|
||||||
|
final var value = Mockito.mock(GetValue.class);
|
||||||
|
when(resp.getValue()).thenReturn(value);
|
||||||
|
service.refreshConsul();
|
||||||
|
Mockito.verify(value).getDecodedValue();
|
||||||
|
assertNull(service.getConfig()); // fallback is null
|
||||||
|
// has content, but not valid json
|
||||||
|
when(value.getDecodedValue()).thenReturn("not-json");
|
||||||
|
Assertions.assertDoesNotThrow(service::refreshConsul);
|
||||||
|
assertNull(service.getConfig()); // fallback is null
|
||||||
|
// has json content
|
||||||
|
when(value.getDecodedValue())
|
||||||
|
.thenReturn(
|
||||||
|
this.objectMapper.writeValueAsString(new PermissionMappingConfigProperties()));
|
||||||
|
service.refreshConsul();
|
||||||
|
assertEquals(new PermissionMappingConfigProperties(), service.getConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test {@link FallbackPermissionMapLookupService#getDefaultKeycloakClientId()}
|
||||||
|
* to ensure it returns the correct default Keycloak client ID when the config is set.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void testGetDefaultKeycloakClientId() {
|
||||||
|
final var defaultConfig = new PermissionMappingConfigProperties();
|
||||||
|
defaultConfig.setDefaultKeycloakClientId("default-client-id");
|
||||||
|
|
||||||
|
final var service = new FallbackPermissionMapLookupService(
|
||||||
|
defaultConfig, this.consulClient, this.objectMapper);
|
||||||
|
|
||||||
|
assertEquals("default-client-id", service.getDefaultKeycloakClientId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testMatchRuleByPath() {
|
||||||
|
final var defaultConfig = new PermissionMappingConfigProperties();
|
||||||
|
final var ruleA = PermissionMappingConfigProperties.Rule.builder()
|
||||||
|
.keycloakClientId("service-A").pathPrefix("/a/").build();
|
||||||
|
final var ruleB = PermissionMappingConfigProperties.Rule.builder()
|
||||||
|
.keycloakClientId("service-B").pathPrefix("/b/").build();
|
||||||
|
defaultConfig.setRules(List.of(ruleA, ruleB));
|
||||||
|
final var service = new FallbackPermissionMapLookupService(
|
||||||
|
defaultConfig, this.consulClient, this.objectMapper);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ruleB,
|
||||||
|
service.matchRuleByPath("/b/items/123").orElseThrow());
|
||||||
|
assertEquals(
|
||||||
|
Optional.empty(),
|
||||||
|
service.matchRuleByPath("/c/items/123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+634
@@ -0,0 +1,634 @@
|
|||||||
|
package com.cleverthis.authservice.service.impl;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
|
||||||
|
import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
|
||||||
|
import com.cleverthis.authservice.dto.TokenResponse;
|
||||||
|
import com.cleverthis.authservice.dto.VerificationResponse;
|
||||||
|
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
|
||||||
|
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
|
||||||
|
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
|
||||||
|
import com.cleverthis.authservice.exception.AuthenticationException;
|
||||||
|
import com.cleverthis.authservice.exception.TokenVerificationException;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import okhttp3.mockwebserver.MockResponse;
|
||||||
|
import okhttp3.mockwebserver.MockWebServer;
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.keycloak.representations.idm.GroupRepresentation;
|
||||||
|
import org.keycloak.representations.idm.MemberRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationDomainRepresentation;
|
||||||
|
import org.keycloak.representations.idm.OrganizationRepresentation;
|
||||||
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
class KeycloakIdentityManagerServiceTest {
|
||||||
|
private MockWebServer mockBackEnd;
|
||||||
|
private KeycloakAdminReactiveClient adminClient;
|
||||||
|
private KeycloakIdentityManagerService service;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@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()
|
||||||
|
.keycloakHost("http://localhost:" + this.mockBackEnd.getPort() + "/")
|
||||||
|
.realm("test-realm")
|
||||||
|
.clientId("test-client")
|
||||||
|
.clientSecret("client-password")
|
||||||
|
.build();
|
||||||
|
this.adminClient = Mockito.mock(KeycloakAdminReactiveClient.class);
|
||||||
|
this.service = Mockito.spy(new KeycloakIdentityManagerService(
|
||||||
|
webClient, this.adminClient, configProperties));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() throws IOException {
|
||||||
|
this.mockBackEnd.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_success() throws JsonProcessingException, InterruptedException {
|
||||||
|
// Arrange
|
||||||
|
final var username = "test-user";
|
||||||
|
final var password = "test-password";
|
||||||
|
final var mockTokenResponse = new TokenResponse("access-token", 3600, 3600,
|
||||||
|
"refresh-token", "bearer", "id-token", 0, "session-id", "openid");
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(this.objectMapper.writeValueAsString(mockTokenResponse))
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.login(username, password);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> response.getAccessToken().equals("access-token") &&
|
||||||
|
response.getRefreshToken().equals("refresh-token"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
|
||||||
|
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_failure_invalidCredentials() {
|
||||||
|
// Arrange
|
||||||
|
final var username = "wrong-user";
|
||||||
|
final var password = "wrong-password";
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(401)
|
||||||
|
.setBody(
|
||||||
|
"{\"error\":\"invalid_grant\",\"error_description\":\"Invalid user credentials\"}")
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.login(username, password);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectErrorMatches(throwable -> throwable instanceof AuthenticationException &&
|
||||||
|
throwable.getMessage()
|
||||||
|
.contains("Login failed: Invalid credentials or Keycloak error."))
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyToken_success() throws JsonProcessingException, InterruptedException {
|
||||||
|
// Arrange
|
||||||
|
final var accessToken = "valid-access-token";
|
||||||
|
final var mockVerificationResponse = VerificationResponse.builder()
|
||||||
|
.clientId("test-client-id")
|
||||||
|
.active(true)
|
||||||
|
.emailVerified(true)
|
||||||
|
.preferredUsername("test-user")
|
||||||
|
.sub("user-sub")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.verifyToken(accessToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> "test-client-id".equals(response.getClientId()) &&
|
||||||
|
"test-user".equals(response.getPreferredUsername()) &&
|
||||||
|
Boolean.TRUE.equals(response.getEmailVerified()))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
|
||||||
|
assertEquals("/realms/test-realm/protocol/openid-connect/token/introspect",
|
||||||
|
recordedRequest.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyToken_failure_inactiveToken() throws JsonProcessingException {
|
||||||
|
// Arrange
|
||||||
|
final var accessToken = "inactive-access-token";
|
||||||
|
final var mockVerificationResponse = VerificationResponse.builder()
|
||||||
|
.sub("user-sub")
|
||||||
|
.active(false)
|
||||||
|
.username("test-user")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(this.objectMapper.writeValueAsString(mockVerificationResponse))
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.verifyToken(accessToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectErrorMatches(throwable -> throwable instanceof TokenVerificationException &&
|
||||||
|
throwable.getMessage().contains("Token is invalid or expired."))
|
||||||
|
.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_success() throws InterruptedException {
|
||||||
|
// Arrange
|
||||||
|
final var refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.logout(refreshToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
|
||||||
|
assertEquals("/realms/test-realm/protocol/openid-connect/revoke",
|
||||||
|
recordedRequest.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_failure_invalidToken() {
|
||||||
|
// Arrange
|
||||||
|
final var refreshToken = "invalid-refresh-token";
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(400)
|
||||||
|
.setBody(
|
||||||
|
"{\"error\":\"invalid_request\",\"error_description\":\"Invalid token or token has been revoked\"}")
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.logout(refreshToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete(); // Expecting no error due to handling of 400 as a soft failure
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkResourcePermission_success() throws InterruptedException {
|
||||||
|
// Arrange
|
||||||
|
final var accessToken = "valid-token";
|
||||||
|
final var clientId = "resource-client";
|
||||||
|
final var resourceUri = "/resources/123";
|
||||||
|
final var scope = "read";
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody("{\"response\":\"success\"}")
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result =
|
||||||
|
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(true)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
RecordedRequest recordedRequest = this.mockBackEnd.takeRequest();
|
||||||
|
assertEquals("/realms/test-realm/protocol/openid-connect/token", recordedRequest.getPath());
|
||||||
|
assertEquals("Bearer valid-token", recordedRequest.getHeader("Authorization"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkResourcePermission_permissionDenied() {
|
||||||
|
// Arrange
|
||||||
|
final var accessToken = "valid-token";
|
||||||
|
final var clientId = "resource-client";
|
||||||
|
final var resourceUri = "/resources/123";
|
||||||
|
final var scope = "read";
|
||||||
|
|
||||||
|
this.mockBackEnd.enqueue(new MockResponse()
|
||||||
|
.setResponseCode(403)
|
||||||
|
.setBody("{\"error\":\"permission_denied\"}")
|
||||||
|
.addHeader("Content-Type", "application/json"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result =
|
||||||
|
this.service.checkResourcePermission(accessToken, clientId, resourceUri, scope);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNext(false)
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var request = new CreateOrganizationRequest(
|
||||||
|
"Test Organization", List.of("test.org"), "", Map.of()
|
||||||
|
);
|
||||||
|
final var mockResponse = new OrganizationRepresentation();
|
||||||
|
mockResponse.setId("123");
|
||||||
|
mockResponse.setName("Test Organization");
|
||||||
|
mockResponse.addDomain(new OrganizationDomainRepresentation("test.org"));
|
||||||
|
mockResponse.setDescription("");
|
||||||
|
mockResponse.setEnabled(true);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.createOrganization(any()))
|
||||||
|
.thenReturn(Mono.just(mockResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.createOrganization(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> "123".equals(response.getId()) &&
|
||||||
|
"Test Organization".equals(response.getName()))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrganizations_success() {
|
||||||
|
// Arrange
|
||||||
|
final var mockOrganizations = List.of(
|
||||||
|
new OrganizationRepresentation() {{
|
||||||
|
setId("org1");
|
||||||
|
setName("Organization 1");
|
||||||
|
addDomain(new OrganizationDomainRepresentation("domain1.org"));
|
||||||
|
}},
|
||||||
|
new OrganizationRepresentation() {{
|
||||||
|
setId("org2");
|
||||||
|
setName("Organization 2");
|
||||||
|
addDomain(new OrganizationDomainRepresentation("domain2.org"));
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getOrganizations())
|
||||||
|
.thenReturn(Mono.just(mockOrganizations));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.getOrganizations();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(orgList -> orgList.size() == 2
|
||||||
|
&& "Organization 1".equals(orgList.getFirst().getName())
|
||||||
|
&& "domain1.org".equals(
|
||||||
|
orgList.getFirst().getDomains().iterator().next().getName())
|
||||||
|
&& "Organization 2".equals(orgList.get(1).getName()))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrganizationById_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "abc123";
|
||||||
|
final var orgRepresentation = new OrganizationRepresentation();
|
||||||
|
orgRepresentation.setId(orgId);
|
||||||
|
orgRepresentation.setName("Test Organization");
|
||||||
|
orgRepresentation.addDomain(new OrganizationDomainRepresentation("test.org"));
|
||||||
|
orgRepresentation.setDescription("Test Description");
|
||||||
|
orgRepresentation.setEnabled(true);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getOrganizationById(orgId))
|
||||||
|
.thenReturn(Mono.just(orgRepresentation));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.getOrganizationById(orgId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> "abc123".equals(response.getId())
|
||||||
|
&& "Test Organization".equals(response.getName())
|
||||||
|
&& "Test Description".equals(response.getDescription())
|
||||||
|
&& Boolean.TRUE.equals(response.getEnabled())
|
||||||
|
&& response.getDomains().iterator().next().getName().equals("test.org"))
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "org123";
|
||||||
|
final var updateRequest = new UpdateOrganizationRequest(
|
||||||
|
List.of("updated.org"), "Updated Description", Map.of("key", List.of("value"))
|
||||||
|
);
|
||||||
|
final var existingOrg = new OrganizationRepresentation();
|
||||||
|
existingOrg.setId(orgId);
|
||||||
|
existingOrg.setDescription("Old Description");
|
||||||
|
existingOrg.addDomain(new OrganizationDomainRepresentation("old.org"));
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getOrganizationById(orgId))
|
||||||
|
.thenReturn(Mono.just(existingOrg));
|
||||||
|
Mockito.when(this.adminClient.updateOrganization(orgId, existingOrg))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.updateOrganization(orgId, updateRequest);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).updateOrganization(orgId, existingOrg);
|
||||||
|
assertEquals("Updated Description", existingOrg.getDescription());
|
||||||
|
assertEquals("value", existingOrg.getAttributes().get("key").getFirst());
|
||||||
|
assertEquals(1, existingOrg.getDomains().size());
|
||||||
|
assertEquals("updated.org", existingOrg.getDomains().iterator().next().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "test-org-id";
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.deleteOrganization(orgId))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.deleteOrganization(orgId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).deleteOrganization(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inviteUserToOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "test-org-id";
|
||||||
|
final var request = new InviteUserRequest("test@example.com", "John", "Wick");
|
||||||
|
final var invitationRequest = new KeycloakInvitationRequest();
|
||||||
|
invitationRequest.setEmail(request.getEmail());
|
||||||
|
invitationRequest.setFirstName(request.getFirstName());
|
||||||
|
invitationRequest.setLastName(request.getLastName());
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.inviteUserToOrganization(any(), any()))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.inviteUserToOrganization(orgId, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).inviteUserToOrganization(orgId, invitationRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getOrganizationMembers_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "test-org-id";
|
||||||
|
final var mockMembers = List.of(
|
||||||
|
// I asked Gemini, and this is an antipattern that no one mentions
|
||||||
|
// when I learned Java. Basically, it will create a new anonymous
|
||||||
|
// class for every instance. In this case, we have two.
|
||||||
|
// Gemini suggests we use a method to create a user and then put it in the list,
|
||||||
|
// but a dedicated method is not as compact as double brace initialization.
|
||||||
|
// This test is generated by gemini 2.5 pro. I think it's ok to use here
|
||||||
|
// because it's a unit test, and I blame keycloak to not offer a builder.
|
||||||
|
// DO NOT use this pattern in the main source set.
|
||||||
|
new MemberRepresentation() {{
|
||||||
|
setId("user1");
|
||||||
|
setUsername("user1Username");
|
||||||
|
setFirstName("User1");
|
||||||
|
setLastName("One");
|
||||||
|
setEmail("user1@example.com");
|
||||||
|
setEnabled(true);
|
||||||
|
}},
|
||||||
|
new MemberRepresentation() {{
|
||||||
|
setId("user2");
|
||||||
|
setUsername("user2Username");
|
||||||
|
setFirstName("User2");
|
||||||
|
setLastName("Two");
|
||||||
|
setEmail("user2@example.com");
|
||||||
|
setEnabled(true);
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getOrganizationMembers(orgId))
|
||||||
|
.thenReturn(Mono.just(mockMembers));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.getOrganizationMembers(orgId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(members -> members.size() == 2
|
||||||
|
&& "user1".equals(members.getFirst().getId())
|
||||||
|
&& "user1@example.com".equals(members.getFirst().getEmail())
|
||||||
|
&& "user2".equals(members.get(1).getId())
|
||||||
|
&& "user2@example.com".equals(members.get(1).getEmail()))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).getOrganizationMembers(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addMemberToOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "test-org-id";
|
||||||
|
final var userId = "test-user-id";
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.addMemberToOrganization(orgId, userId))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.addMemberToOrganization(orgId, userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).addMemberToOrganization(orgId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberFromOrganization_success() {
|
||||||
|
// Arrange
|
||||||
|
final var orgId = "test-org-id";
|
||||||
|
final var userId = "test-user-id";
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.removeMemberFromOrganization(orgId, userId))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.removeMemberFromOrganization(orgId, userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).removeMemberFromOrganization(orgId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSubGroup_success() {
|
||||||
|
// Arrange
|
||||||
|
final var parentGroupId = "parent-group-id";
|
||||||
|
final var request = new CreateGroupRequest(
|
||||||
|
"Test SubGroup", "", Map.of("key", List.of("value")));
|
||||||
|
final var mockGroupRepresentation = new GroupRepresentation();
|
||||||
|
mockGroupRepresentation.setId("sub-group-id");
|
||||||
|
mockGroupRepresentation.setName(request.getName());
|
||||||
|
mockGroupRepresentation.setAttributes(request.getAttributes());
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.createSubGroup(parentGroupId, mockGroupRepresentation))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.createSubGroup(parentGroupId, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> "Test SubGroup".equals(response.getName()) &&
|
||||||
|
response.getAttributes().get("key").contains("value"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).createSubGroup(Mockito.eq(parentGroupId), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupById_success() {
|
||||||
|
// Arrange
|
||||||
|
final var groupId = "test-group-id";
|
||||||
|
final var mockGroup = new GroupRepresentation();
|
||||||
|
mockGroup.setId(groupId);
|
||||||
|
mockGroup.setName("Test Group");
|
||||||
|
mockGroup.setPath("/test-group");
|
||||||
|
mockGroup.setDescription("Test Group Description");
|
||||||
|
mockGroup.setAttributes(Map.of("key", List.of("value1", "value2")));
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getGroupById(groupId))
|
||||||
|
.thenReturn(Mono.just(mockGroup));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.getGroupById(groupId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(response -> response.getId().equals(groupId) &&
|
||||||
|
response.getName().equals("Test Group") &&
|
||||||
|
response.getPath().equals("/test-group") &&
|
||||||
|
response.getDescription().equals("Test Group Description") &&
|
||||||
|
response.getAttributes().get("key").contains("value1"))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).getGroupById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listSubGroups_success() {
|
||||||
|
// Arrange
|
||||||
|
final var parentGroupId = "parent-group-id";
|
||||||
|
final var subGroups = List.of(
|
||||||
|
new GroupRepresentation() {{
|
||||||
|
setId("sub-group-1");
|
||||||
|
setName("SubGroup1");
|
||||||
|
}},
|
||||||
|
new GroupRepresentation() {{
|
||||||
|
setId("sub-group-2");
|
||||||
|
setName("SubGroup2");
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.listSubGroups(parentGroupId))
|
||||||
|
.thenReturn(Mono.just(subGroups));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.listSubGroups(parentGroupId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(groups -> groups.size() == 2 &&
|
||||||
|
"SubGroup1".equals(groups.getFirst().getName()) &&
|
||||||
|
"sub-group-2".equals(groups.get(1).getId()))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).listSubGroups(parentGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGroupMembers_success() {
|
||||||
|
// Arrange
|
||||||
|
final var groupId = "test-group-id";
|
||||||
|
final var mockMembers = List.of(
|
||||||
|
new UserRepresentation() {{
|
||||||
|
setId("user1");
|
||||||
|
setUsername("user1Username");
|
||||||
|
setFirstName("User1");
|
||||||
|
setLastName("One");
|
||||||
|
setEmail("user1@example.com");
|
||||||
|
setEnabled(true);
|
||||||
|
}},
|
||||||
|
new UserRepresentation() {{
|
||||||
|
setId("user2");
|
||||||
|
setUsername("user2Username");
|
||||||
|
setFirstName("User2");
|
||||||
|
setLastName("Two");
|
||||||
|
setEmail("user2@example.com");
|
||||||
|
setEnabled(true);
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
|
||||||
|
Mockito.when(this.adminClient.getGroupMembers(groupId))
|
||||||
|
.thenReturn(Mono.just(mockMembers));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final var result = this.service.getGroupMembers(groupId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
StepVerifier.create(result)
|
||||||
|
.expectNextMatches(members -> members.size() == 2
|
||||||
|
&& "user1".equals(members.getFirst().getId())
|
||||||
|
&& "user1@example.com".equals(members.getFirst().getEmail())
|
||||||
|
&& "user2".equals(members.get(1).getId())
|
||||||
|
&& "user2@example.com".equals(members.get(1).getEmail()))
|
||||||
|
.verifyComplete();
|
||||||
|
|
||||||
|
Mockito.verify(this.adminClient).getGroupMembers(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
spring:
|
||||||
|
cloud:
|
||||||
|
consul:
|
||||||
|
config:
|
||||||
|
enabled: false
|
||||||
|
otel:
|
||||||
|
logs:
|
||||||
|
exporter: none
|
||||||
|
traces:
|
||||||
|
exporter: none
|
||||||
|
metrics:
|
||||||
|
exporter: none
|
||||||
Reference in New Issue
Block a user