diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 619202e..0000000 --- a/.gitignore +++ /dev/null @@ -1,133 +0,0 @@ -# ============================================================================ -# Lions User Manager - Server Implementation Quarkus - .gitignore -# ============================================================================ - -# Maven -target/ -pom.xml.tag -pom.xml.releaseBackup -pom.xml.versionsBackup -pom.xml.next -release.properties -dependency-reduced-pom.xml -buildNumber.properties -.mvn/timing.properties -.mvn/wrapper/maven-wrapper.jar - -# Build artifacts -*.jar -*.war -*.ear -*.class -*.idx - -# Eclipse -.project -.classpath -.settings/ -.metadata/ -bin/ - -# IntelliJ IDEA -.idea/ -*.iml -*.iws -*.ipr -out/ - -# NetBeans -nbproject/ -nbbuild/ -nbdist/ -.nb-gradle/ - -# VS Code -.vscode/ -*.code-workspace - -# Mac -.DS_Store - -# Windows -Thumbs.db -ehthumbs.db -Desktop.ini - -# Logs -logs/ -*.log -*.log.* -hs_err_pid*.log - -# Quarkus -.quarkus/ -quarkus-app/ -quarkus-run.jar -quarkus-*.dat - -# Temporary files -*.tmp -*.bak -*.swp -*~ -*.orig - -# Test files and reports -test_output*.txt -surefire-reports/ -failsafe-reports/ -*.dump -*.dumpstream - -# Test coverage -.jacoco/ -jacoco.exec -coverage/ -target/site/jacoco/ - -# Application specific -application-local.properties -application-*.local.properties - -# Configuration files with sensitive data -*.local.json - -# Token and authentication files -token.json -token.txt -*.token - -# Generated sources -generated-sources/ -generated-test-sources/ - -# Maven status -maven-status/ - -# Build metrics -build-metrics.json - -# Quarkus Dev Services -.devservices/ - -# Fichiers META-INF générés (reflection-config.json est généré par Quarkus) -**/META-INF/reflection-config.json - -# IDE specific -*.sublime-project -*.sublime-workspace - -# OS specific -.DS_Store? -._* -.Spotlight-V100 -.Trashes - -# Lombok configuration (généré automatiquement) -lombok.config - -# Environment files -.env -.env.local -.env.*.local - diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..2b45606 --- /dev/null +++ b/lombok.config @@ -0,0 +1,6 @@ +# This file configures Lombok for the project +# See https://projectlombok.org/features/configuration + +# Add @Generated annotation to all generated code +# This allows JaCoCo to automatically exclude Lombok-generated code from coverage +lombok.addLombokGeneratedAnnotation = true diff --git a/pom.xml b/pom.xml index e82b319..b20f4d0 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,8 @@ quarkus-keycloak-admin-rest-client + + io.quarkus @@ -147,12 +149,6 @@ mockito-junit-jupiter test - - org.mockito - mockito-inline - 5.2.0 - test - diff --git a/script/docker/.env.example b/script/docker/.env.example new file mode 100644 index 0000000..ffc6e47 --- /dev/null +++ b/script/docker/.env.example @@ -0,0 +1,13 @@ +# Base de données +DB_NAME=lions_user_manager +DB_USER=lions +DB_PASSWORD=lions +DB_PORT=5432 + +# Keycloak +KC_ADMIN=admin +KC_ADMIN_PASSWORD=admin +KC_PORT=8180 + +# Serveur +SERVER_PORT=8080 diff --git a/script/docker/dependencies-docker-compose.yml b/script/docker/dependencies-docker-compose.yml new file mode 100644 index 0000000..ff7dbf6 --- /dev/null +++ b/script/docker/dependencies-docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: ${DB_NAME:-lions_user_manager} + POSTGRES_USER: ${DB_USER:-lions} + POSTGRES_PASSWORD: ${DB_PASSWORD:-lions} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lions} -d ${DB_NAME:-lions_user_manager}"] + interval: 5s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.3.3 + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-lions_user_manager} + KC_DB_USERNAME: ${DB_USER:-lions} + KC_DB_PASSWORD: ${DB_PASSWORD:-lions} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin} + ports: + - "${KC_PORT:-8180}:8080" + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres_data: diff --git a/script/docker/docker-compose.yml b/script/docker/docker-compose.yml new file mode 100644 index 0000000..fdc0a14 --- /dev/null +++ b/script/docker/docker-compose.yml @@ -0,0 +1,52 @@ +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: ${DB_NAME:-lions_user_manager} + POSTGRES_USER: ${DB_USER:-lions} + POSTGRES_PASSWORD: ${DB_PASSWORD:-lions} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lions} -d ${DB_NAME:-lions_user_manager}"] + interval: 5s + timeout: 5s + retries: 5 + + keycloak: + image: quay.io/keycloak/keycloak:26.3.3 + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-lions_user_manager} + KC_DB_USERNAME: ${DB_USER:-lions} + KC_DB_PASSWORD: ${DB_PASSWORD:-lions} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin} + ports: + - "${KC_PORT:-8180}:8080" + depends_on: + postgres: + condition: service_healthy + + lions-user-manager-server: + build: + context: ../.. + dockerfile: src/main/docker/Dockerfile.jvm + ports: + - "${SERVER_PORT:-8080}:8080" + environment: + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-lions_user_manager} + QUARKUS_DATASOURCE_USERNAME: ${DB_USER:-lions} + QUARKUS_DATASOURCE_PASSWORD: ${DB_PASSWORD:-lions} + KEYCLOAK_SERVER_URL: http://keycloak:8080 + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_started + +volumes: + postgres_data: diff --git a/script/docker/run-dev.bat b/script/docker/run-dev.bat new file mode 100644 index 0000000..c2695f7 --- /dev/null +++ b/script/docker/run-dev.bat @@ -0,0 +1,5 @@ +@echo off +REM Demarre les dependances (postgres + keycloak) puis le serveur en mode dev (mvn quarkus:dev -P dev) +cd /d "%~dp0\..\.." +docker-compose -f script/docker/dependencies-docker-compose.yml up -d +mvn quarkus:dev -P dev diff --git a/script/docker/run-dev.sh b/script/docker/run-dev.sh new file mode 100644 index 0000000..a15508d --- /dev/null +++ b/script/docker/run-dev.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Démarre les dépendances (postgres + keycloak) puis le serveur en mode dev (mvn quarkus:dev -P dev) +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../.." +docker-compose -f script/docker/dependencies-docker-compose.yml up -d +mvn quarkus:dev -P dev diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..319902a --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,11 @@ +FROM registry.access.redhat.com/ubi8/openjdk-17:1.20 +ENV LANGUAGE='en_US:en' +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" +ENTRYPOINT ["/opt/jboss/container/java/run/run-java.sh"] diff --git a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java index de5f7c1..d4e7bde 100644 --- a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java +++ b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClient.java @@ -52,11 +52,18 @@ public interface KeycloakAdminClient { boolean realmExists(String realmName); /** - * Récupère tous les realms disponibles dans Keycloak - * @return liste des noms de realms + * Récupère la liste de tous les realms + * @return Liste des noms de realms */ java.util.List getAllRealms(); + /** + * Récupère la liste des clientId d'un realm + * @param realmName nom du realm + * @return Liste des clientId + */ + java.util.List getRealmClients(String realmName); + /** * Ferme la connexion Keycloak */ diff --git a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java index 4070595..6a35cf5 100644 --- a/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java +++ b/src/main/java/dev/lions/user/manager/client/KeycloakAdminClientImpl.java @@ -1,33 +1,37 @@ package dev.lions.user.manager.client; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.runtime.Startup; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.core.HttpHeaders; -import jakarta.ws.rs.core.MediaType; +import jakarta.inject.Inject; import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.faulttolerance.CircuitBreaker; import org.eclipse.microprofile.faulttolerance.Retry; import org.eclipse.microprofile.faulttolerance.Timeout; import org.keycloak.admin.client.Keycloak; -import org.keycloak.admin.client.KeycloakBuilder; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RolesResource; import org.keycloak.admin.client.resource.UsersResource; import jakarta.ws.rs.NotFoundException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * Implémentation du client Keycloak Admin + * Utilise le bean Keycloak géré par Quarkus (quarkus-keycloak-admin-rest-client) + * qui respecte la configuration Jackson (fail-on-unknown-properties=false) * Utilise Circuit Breaker, Retry et Timeout pour la résilience */ @ApplicationScoped @@ -35,38 +39,23 @@ import java.util.Map; @Slf4j public class KeycloakAdminClientImpl implements KeycloakAdminClient { - @ConfigProperty(name = "lions.keycloak.server-url", defaultValue = "") + @Inject + Keycloak keycloak; + + @ConfigProperty(name = "lions.keycloak.server-url") String serverUrl; - @ConfigProperty(name = "lions.keycloak.admin-realm", defaultValue = "master") + @ConfigProperty(name = "lions.keycloak.admin-realm") String adminRealm; - @ConfigProperty(name = "lions.keycloak.admin-client-id", defaultValue = "admin-cli") + @ConfigProperty(name = "lions.keycloak.admin-client-id") String adminClientId; - @ConfigProperty(name = "lions.keycloak.admin-username", defaultValue = "admin") + @ConfigProperty(name = "lions.keycloak.admin-username") String adminUsername; - @ConfigProperty(name = "lions.keycloak.admin-password", defaultValue = "") - String adminPassword; - - @ConfigProperty(name = "lions.keycloak.connection-pool-size", defaultValue = "10") - Integer connectionPoolSize; - - @ConfigProperty(name = "lions.keycloak.timeout-seconds", defaultValue = "30") - Integer timeoutSeconds; - - private Keycloak keycloak; - @PostConstruct void init() { - // Ne pas initialiser si les propriétés essentielles sont vides (ex: en mode test) - if (serverUrl == null || serverUrl.isEmpty()) { - log.debug("Configuration Keycloak non disponible - mode test ou configuration manquante"); - this.keycloak = null; - return; - } - log.info("========================================"); log.info("Initialisation du client Keycloak Admin"); log.info("========================================"); @@ -74,29 +63,8 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { log.info("Admin Realm: {}", adminRealm); log.info("Admin Client ID: {}", adminClientId); log.info("Admin Username: {}", adminUsername); - log.info("Connection Pool Size: {}", connectionPoolSize); - log.info("Timeout: {} secondes", timeoutSeconds); - - try { - this.keycloak = KeycloakBuilder.builder() - .serverUrl(serverUrl) - .realm(adminRealm) - .clientId(adminClientId) - .username(adminUsername) - .password(adminPassword) - .build(); - - log.info("✅ Client Keycloak initialisé (connexion lazy)"); - log.info("La connexion sera établie lors de la première requête API"); - } catch (Exception e) { - log.warn("⚠️ Échec de l'initialisation du client Keycloak"); - log.warn("URL: {}", serverUrl); - log.warn("Realm: {}", adminRealm); - log.warn("Username: {}", adminUsername); - log.warn("Message: {}", e.getMessage()); - // Ne pas bloquer le démarrage - la connexion sera tentée lors du premier appel - this.keycloak = null; - } + log.info("✅ Client Keycloak initialisé via Quarkus CDI (connexion lazy)"); + log.info("La connexion sera établie lors de la première requête API"); } @Override @@ -104,10 +72,6 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { @Timeout(value = 30, unit = ChronoUnit.SECONDS) @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public Keycloak getInstance() { - if (keycloak == null) { - log.warn("Instance Keycloak null, tentative de réinitialisation..."); - init(); - } return keycloak; } @@ -117,7 +81,7 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public RealmResource getRealm(String realmName) { try { - return getInstance().realm(realmName); + return keycloak.realm(realmName); } catch (Exception e) { log.error("Erreur lors de la récupération du realm {}: {}", realmName, e.getMessage()); throw new RuntimeException("Impossible de récupérer le realm: " + realmName, e); @@ -143,10 +107,9 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { @Override public boolean isConnected() { try { - if (keycloak == null) { - return false; - } - keycloak.serverInfo().getInfo(); + // getAccessTokenString() n'implique pas la désérialisation de ServerInfoRepresentation + // (qui échoue sur le champ inconnu "cpuInfo" avec Keycloak 26+) + keycloak.tokenManager().getAccessTokenString(); return true; } catch (Exception e) { log.warn("Keycloak non connecté: {}", e.getMessage()); @@ -157,17 +120,12 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { @Override public boolean realmExists(String realmName) { try { - // Essayer d'obtenir simplement la liste des rôles du realm - // Si le realm n'existe pas, cela lancera une NotFoundException - // Si le realm existe mais a des problèmes de désérialisation, on suppose qu'il existe getRealm(realmName).roles().list(); return true; } catch (NotFoundException e) { log.debug("Realm {} n'existe pas", realmName); return false; } catch (Exception e) { - // En cas d'erreur (comme bruteForceStrategy lors de .toRepresentation()), - // on suppose que le realm existe car l'erreur indique qu'on a pu le contacter log.debug("Erreur lors de la vérification du realm {} (probablement il existe): {}", realmName, e.getMessage()); return true; @@ -180,64 +138,96 @@ public class KeycloakAdminClientImpl implements KeycloakAdminClient { @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) public List getAllRealms() { try { - log.debug("Récupération de tous les realms depuis Keycloak via API REST directe"); - - // Obtenir un token d'accès pour l'API REST - Keycloak keycloakInstance = getInstance(); - String accessToken = keycloakInstance.tokenManager().getAccessTokenString(); - - // Utiliser un client HTTP REST pour appeler directement l'API Keycloak - // et parser uniquement les noms des realms depuis le JSON - Client client = ClientBuilder.newClient(); - try { - String realmsUrl = serverUrl + "/admin/realms"; - - @SuppressWarnings("unchecked") - List> realmsJson = client.target(realmsUrl) - .request(MediaType.APPLICATION_JSON) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .get(List.class); - - List realmNames = new ArrayList<>(); - if (realmsJson != null) { - for (Map realm : realmsJson) { - Object realmNameObj = realm.get("realm"); - if (realmNameObj != null) { - String realmName = realmNameObj.toString(); - if (!realmName.isEmpty()) { - realmNames.add(realmName); - } - } - } - realmNames.sort(String::compareTo); - } - - log.info("Récupération réussie: {} realms trouvés", realmNames.size()); - return realmNames; - } finally { - client.close(); + log.debug("Récupération de tous les realms depuis Keycloak"); + // Appel HTTP direct pour éviter l'erreur de désérialisation de RealmRepresentation + // (champ bruteForceStrategy inconnu dans la version de la librairie cliente) + String token = keycloak.tokenManager().getAccessTokenString(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(serverUrl + "/admin/realms")) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Keycloak returned HTTP " + response.statusCode()); } + + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + List> realmMaps = mapper.readValue( + response.body(), new TypeReference<>() {}); + + List realms = realmMaps.stream() + .map(r -> (String) r.get("realm")) + .filter(r -> r != null) + .collect(Collectors.toList()); + + log.debug("Realms récupérés: {}", realms); + return realms; } catch (Exception e) { - log.error("Erreur lors de la récupération des realms: {}", e.getMessage(), e); - // En cas d'erreur, retourner une liste vide plutôt que des données fictives - return Collections.emptyList(); + log.error("Erreur lors de la récupération de tous les realms: {}", e.getMessage()); + throw new RuntimeException("Impossible de récupérer la liste des realms", e); + } + } + + @Override + @Retry(maxRetries = 3, delay = 2, delayUnit = ChronoUnit.SECONDS) + @Timeout(value = 30, unit = ChronoUnit.SECONDS) + @CircuitBreaker(requestVolumeThreshold = 10, failureRatio = 0.5, delay = 5000) + public List getRealmClients(String realmName) { + try { + log.debug("Récupération des clients du realm {}", realmName); + String token = keycloak.tokenManager().getAccessTokenString(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(serverUrl + "/admin/realms/" + realmName + "/clients")) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Keycloak returned HTTP " + response.statusCode()); + } + + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + List> clientMaps = mapper.readValue( + response.body(), new TypeReference<>() {}); + + List clients = clientMaps.stream() + .map(c -> (String) c.get("clientId")) + .filter(c -> c != null) + .collect(Collectors.toList()); + + log.debug("Clients récupérés pour {}: {}", realmName, clients); + return clients; + } catch (Exception e) { + log.error("Erreur lors de la récupération des clients du realm {}: {}", realmName, e.getMessage()); + throw new RuntimeException("Impossible de récupérer les clients du realm: " + realmName, e); } } @PreDestroy @Override public void close() { - if (keycloak != null) { - log.info("Fermeture de la connexion Keycloak..."); - keycloak.close(); - keycloak = null; - } + log.info("Fermeture de la connexion Keycloak..."); + // Le cycle de vie est géré par Quarkus CDI } @Override public void reconnect() { - log.info("Reconnexion à Keycloak..."); - close(); - init(); + log.info("Reconnexion à Keycloak... (géré par Quarkus CDI)"); + // Le bean Keycloak est géré par Quarkus, pas de reconnexion manuelle nécessaire } } diff --git a/src/main/java/dev/lions/user/manager/config/JacksonConfig.java b/src/main/java/dev/lions/user/manager/config/JacksonConfig.java index b59e643..3cefc7d 100644 --- a/src/main/java/dev/lions/user/manager/config/JacksonConfig.java +++ b/src/main/java/dev/lions/user/manager/config/JacksonConfig.java @@ -4,17 +4,19 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.jackson.ObjectMapperCustomizer; import jakarta.inject.Singleton; +import lombok.extern.slf4j.Slf4j; /** - * Configuration Jackson pour ignorer les propriétés inconnues - * Nécessaire pour la compatibilité avec les versions récentes de Keycloak + * Configure Jackson globally to ignore unknown JSON properties. + * This is required for forward compatibility with newer Keycloak versions (e.g. cpuInfo field). */ @Singleton +@Slf4j public class JacksonConfig implements ObjectMapperCustomizer { @Override public void customize(ObjectMapper objectMapper) { - // Ignorer les propriétés inconnues pour compatibilité Keycloak + log.info("### LIONS: Applying Jackson configuration for Keycloak compatibility ###"); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } } diff --git a/src/main/java/dev/lions/user/manager/config/KeycloakJacksonCustomizer.java b/src/main/java/dev/lions/user/manager/config/KeycloakJacksonCustomizer.java new file mode 100644 index 0000000..99b30e8 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/config/KeycloakJacksonCustomizer.java @@ -0,0 +1,33 @@ +package dev.lions.user.manager.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.jackson.ObjectMapperCustomizer; +import jakarta.inject.Singleton; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; + +/** + * Customizer pour Jackson afin d'ignorer les propriétés inconnues dans les + * représentations Keycloak. + * Cela évite les erreurs de désérialisation (comme bruteForceStrategy) lorsque + * le serveur Keycloak + * est plus récent que les bibliothèques clients. + */ +@Singleton +public class KeycloakJacksonCustomizer implements ObjectMapperCustomizer { + + @Override + public void customize(ObjectMapper objectMapper) { + // En plus de la configuration globale, on force les Mix-ins pour les classes + // Keycloak critiques + objectMapper.addMixIn(RealmRepresentation.class, IgnoreUnknownMixin.class); + objectMapper.addMixIn(UserRepresentation.class, IgnoreUnknownMixin.class); + objectMapper.addMixIn(RoleRepresentation.class, IgnoreUnknownMixin.class); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + abstract static class IgnoreUnknownMixin { + } +} diff --git a/src/main/java/dev/lions/user/manager/config/KeycloakTestUserConfig.java b/src/main/java/dev/lions/user/manager/config/KeycloakTestUserConfig.java index f242814..02c6ba1 100644 --- a/src/main/java/dev/lions/user/manager/config/KeycloakTestUserConfig.java +++ b/src/main/java/dev/lions/user/manager/config/KeycloakTestUserConfig.java @@ -1,5 +1,6 @@ package dev.lions.user.manager.config; +import io.quarkus.arc.profile.IfBuildProfile; import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; @@ -21,6 +22,7 @@ import java.util.*; * S'exécute au démarrage de l'application en mode dev */ @Singleton +@IfBuildProfile("dev") @Slf4j public class KeycloakTestUserConfig { diff --git a/src/main/java/dev/lions/user/manager/resource/AuditResource.java b/src/main/java/dev/lions/user/manager/resource/AuditResource.java index aaedd35..b9094a5 100644 --- a/src/main/java/dev/lions/user/manager/resource/AuditResource.java +++ b/src/main/java/dev/lions/user/manager/resource/AuditResource.java @@ -1,364 +1,171 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.api.AuditResourceApi; import dev.lions.user.manager.dto.audit.AuditLogDTO; +import dev.lions.user.manager.dto.common.CountDTO; import dev.lions.user.manager.enums.audit.TypeActionAudit; import dev.lions.user.manager.service.AuditService; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.validation.constraints.NotBlank; -import jakarta.ws.rs.*; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * REST Resource pour l'audit et la consultation des logs + * Implémente l'interface API commune. */ -@Path("/api/audit") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Tag(name = "Audit", description = "Consultation des logs d'audit et statistiques") @Slf4j -public class AuditResource { +@jakarta.enterprise.context.ApplicationScoped +@jakarta.ws.rs.Path("/api/audit") +public class AuditResource implements AuditResourceApi { + + private static final String DEFAULT_REALM_VALUE = "master"; @Inject AuditService auditService; - @POST - @Path("/search") - @Operation(summary = "Rechercher des logs d'audit", description = "Recherche avancée de logs selon critères") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Résultats de recherche"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response searchLogs( - @QueryParam("acteur") String acteurUsername, - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr, - @QueryParam("typeAction") TypeActionAudit typeAction, - @QueryParam("ressourceType") String ressourceType, - @QueryParam("succes") Boolean succes, - @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("pageSize") @DefaultValue("50") int pageSize - ) { + @ConfigProperty(name = "lions.keycloak.admin-realm", defaultValue = DEFAULT_REALM_VALUE) + String defaultRealm; + + @Override + @RolesAllowed({ "admin", "auditor" }) + public List searchLogs( + String acteurUsername, + String dateDebutStr, + String dateFinStr, + TypeActionAudit typeAction, + String ressourceType, + Boolean succes, + int page, + int pageSize) { log.info("POST /api/audit/search - Recherche de logs"); - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - // Utiliser findByActeur si acteurUsername est fourni, sinon findByRealm - List logs; - if (acteurUsername != null && !acteurUsername.isBlank()) { - logs = auditService.findByActeur(acteurUsername, dateDebut, dateFin, page, pageSize); - } else { - // Pour une recherche générale, utiliser findByRealm (on utilise "master" par défaut) - logs = auditService.findByRealm("master", dateDebut, dateFin, page, pageSize); - } - - // Filtrer par typeAction, ressourceType et succes si fournis - if (typeAction != null || ressourceType != null || succes != null) { - logs = logs.stream() + // Utiliser findByActeur si acteurUsername est fourni, sinon findByRealm + List logs; + if (acteurUsername != null && !acteurUsername.isBlank()) { + logs = auditService.findByActeur(acteurUsername, dateDebut, dateFin, page, pageSize); + } else { + // Pour une recherche générale, utiliser findByRealm (on utilise defaultRealm par + // défaut) + logs = auditService.findByRealm(defaultRealm, dateDebut, dateFin, page, pageSize); + } + + // Filtrer par typeAction, ressourceType et succes si fournis + if (typeAction != null || ressourceType != null || succes != null) { + logs = logs.stream() .filter(log -> typeAction == null || typeAction.equals(log.getTypeAction())) .filter(log -> ressourceType == null || ressourceType.equals(log.getRessourceType())) .filter(log -> succes == null || succes == log.isSuccessful()) - .collect(java.util.stream.Collectors.toList()); - } - - return Response.ok(logs).build(); - } catch (Exception e) { - log.error("Erreur lors de la recherche de logs d'audit", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + .collect(Collectors.toList()); } + + return logs; } - @GET - @Path("/actor/{acteurUsername}") - @Operation(summary = "Récupérer les logs d'un acteur", description = "Liste les derniers logs d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des logs"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getLogsByActor( - @Parameter(description = "Username de l'acteur") @PathParam("acteurUsername") @NotBlank String acteurUsername, - @Parameter(description = "Nombre de logs à retourner") @QueryParam("limit") @DefaultValue("100") int limit - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public List getLogsByActor(String acteurUsername, int limit) { log.info("GET /api/audit/actor/{} - Limite: {}", acteurUsername, limit); - - try { - List logs = auditService.findByActeur(acteurUsername, null, null, 0, limit); - return Response.ok(logs).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des logs de l'acteur {}", acteurUsername, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return auditService.findByActeur(acteurUsername, null, null, 0, limit); } - @GET - @Path("/resource/{ressourceType}/{ressourceId}") - @Operation(summary = "Récupérer les logs d'une ressource", description = "Liste les derniers logs d'une ressource spécifique") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des logs"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getLogsByResource( - @PathParam("ressourceType") @NotBlank String ressourceType, - @PathParam("ressourceId") @NotBlank String ressourceId, - @QueryParam("limit") @DefaultValue("100") int limit - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public List getLogsByResource(String ressourceType, String ressourceId, int limit) { log.info("GET /api/audit/resource/{}/{} - Limite: {}", ressourceType, ressourceId, limit); - - try { - List logs = auditService.findByRessource(ressourceType, ressourceId, null, null, 0, limit); - return Response.ok(logs).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des logs de la ressource {}:{}", - ressourceType, ressourceId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return auditService.findByRessource(ressourceType, ressourceId, null, null, 0, limit); } - @GET - @Path("/action/{typeAction}") - @Operation(summary = "Récupérer les logs par type d'action", description = "Liste les logs d'un type d'action spécifique") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des logs"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getLogsByAction( - @PathParam("typeAction") TypeActionAudit typeAction, - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr, - @QueryParam("limit") @DefaultValue("100") int limit - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public List getLogsByAction(TypeActionAudit typeAction, String dateDebutStr, String dateFinStr, + int limit) { log.info("GET /api/audit/action/{} - Limite: {}", typeAction, limit); + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - - List logs = auditService.findByTypeAction(typeAction, "master", dateDebut, dateFin, 0, limit); - return Response.ok(logs).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des logs de type {}", typeAction, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return auditService.findByTypeAction(typeAction, defaultRealm, dateDebut, dateFin, 0, limit); } - @GET - @Path("/stats/actions") - @Operation(summary = "Statistiques par type d'action", description = "Retourne le nombre de logs par type d'action") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Statistiques des actions"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getActionStatistics( - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public Map getActionStatistics(String dateDebutStr, String dateFinStr) { log.info("GET /api/audit/stats/actions - Période: {} à {}", dateDebutStr, dateFinStr); + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - - Map stats = auditService.countByActionType("master", dateDebut, dateFin); - return Response.ok(stats).build(); - } catch (Exception e) { - log.error("Erreur lors du calcul des statistiques d'actions", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return auditService.countByActionType(defaultRealm, dateDebut, dateFin); } - @GET - @Path("/stats/users") - @Operation(summary = "Statistiques par utilisateur", description = "Retourne le nombre d'actions par utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Statistiques des utilisateurs"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getUserActivityStatistics( - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public Map getUserActivityStatistics(String dateDebutStr, String dateFinStr) { log.info("GET /api/audit/stats/users - Période: {} à {}", dateDebutStr, dateFinStr); + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - - Map stats = auditService.countByActeur("master", dateDebut, dateFin); - return Response.ok(stats).build(); - } catch (Exception e) { - log.error("Erreur lors du calcul des statistiques utilisateurs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return auditService.countByActeur(defaultRealm, dateDebut, dateFin); } - @GET - @Path("/stats/failures") - @Operation(summary = "Comptage des échecs", description = "Retourne le nombre d'échecs sur une période") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Nombre d'échecs"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getFailureCount( - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public CountDTO getFailureCount(String dateDebutStr, String dateFinStr) { log.info("GET /api/audit/stats/failures - Période: {} à {}", dateDebutStr, dateFinStr); + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - - Map successVsFailure = auditService.countSuccessVsFailure("master", dateDebut, dateFin); - long count = successVsFailure.getOrDefault("failure", 0L); - return Response.ok(new CountResponse(count)).build(); - } catch (Exception e) { - log.error("Erreur lors du comptage des échecs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + Map successVsFailure = auditService.countSuccessVsFailure(defaultRealm, dateDebut, dateFin); + long count = successVsFailure.getOrDefault("failure", 0L); + return new CountDTO(count); } - @GET - @Path("/stats/success") - @Operation(summary = "Comptage des succès", description = "Retourne le nombre de succès sur une période") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Nombre de succès"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response getSuccessCount( - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public CountDTO getSuccessCount(String dateDebutStr, String dateFinStr) { log.info("GET /api/audit/stats/success - Période: {} à {}", dateDebutStr, dateFinStr); + LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; + LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - try { - LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; - LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - - Map successVsFailure = auditService.countSuccessVsFailure("master", dateDebut, dateFin); - long count = successVsFailure.getOrDefault("success", 0L); - return Response.ok(new CountResponse(count)).build(); - } catch (Exception e) { - log.error("Erreur lors du comptage des succès", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + Map successVsFailure = auditService.countSuccessVsFailure(defaultRealm, dateDebut, dateFin); + long count = successVsFailure.getOrDefault("success", 0L); + return new CountDTO(count); } - @GET - @Path("/export/csv") - @Produces(MediaType.TEXT_PLAIN) - @Operation(summary = "Exporter les logs en CSV", description = "Génère un fichier CSV des logs d'audit") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Fichier CSV généré"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "auditor"}) - public Response exportLogsToCSV( - @QueryParam("dateDebut") String dateDebutStr, - @QueryParam("dateFin") String dateFinStr - ) { + @Override + @RolesAllowed({ "admin", "auditor" }) + public Response exportLogsToCSV(String dateDebutStr, String dateFinStr) { log.info("GET /api/audit/export/csv - Période: {} à {}", dateDebutStr, dateFinStr); try { LocalDateTime dateDebut = dateDebutStr != null ? LocalDateTime.parse(dateDebutStr) : null; LocalDateTime dateFin = dateFinStr != null ? LocalDateTime.parse(dateFinStr) : null; - String csvContent = auditService.exportToCSV("master", dateDebut, dateFin); + String csvContent = auditService.exportToCSV(defaultRealm, dateDebut, dateFin); return Response.ok(csvContent) - .header("Content-Disposition", "attachment; filename=\"audit-logs-" + - LocalDateTime.now().toString().replace(":", "-") + ".csv\"") - .build(); + .header("Content-Disposition", "attachment; filename=\"audit-logs-" + + LocalDateTime.now().toString().replace(":", "-") + ".csv\"") + .build(); } catch (Exception e) { log.error("Erreur lors de l'export CSV des logs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + throw new RuntimeException(e); } } - @DELETE - @Path("/purge") - @Operation(summary = "Purger les anciens logs", description = "Supprime les logs de plus de X jours") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Purge effectuée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response purgeOldLogs( - @QueryParam("joursAnciennete") @DefaultValue("90") int joursAnciennete - ) { + @Override + @RolesAllowed({ "admin" }) + public void purgeOldLogs(int joursAnciennete) { log.info("DELETE /api/audit/purge - Suppression des logs de plus de {} jours", joursAnciennete); - - try { - LocalDateTime dateLimite = LocalDateTime.now().minusDays(joursAnciennete); - auditService.purgeOldLogs(dateLimite); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la purge des logs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - // ==================== DTOs internes ==================== - - @Schema(description = "Réponse de comptage") - public static class CountResponse { - @Schema(description = "Nombre d'éléments") - public long count; - - public CountResponse(long count) { - this.count = count; - } - } - - @Schema(description = "Réponse d'erreur") - public static class ErrorResponse { - @Schema(description = "Message d'erreur") - public String message; - - public ErrorResponse(String message) { - this.message = message; - } + LocalDateTime dateLimite = LocalDateTime.now().minusDays(joursAnciennete); + auditService.purgeOldLogs(dateLimite); } } diff --git a/src/main/java/dev/lions/user/manager/resource/RealmAssignmentResource.java b/src/main/java/dev/lions/user/manager/resource/RealmAssignmentResource.java index 43f69b4..c2fdb32 100644 --- a/src/main/java/dev/lions/user/manager/resource/RealmAssignmentResource.java +++ b/src/main/java/dev/lions/user/manager/resource/RealmAssignmentResource.java @@ -1,38 +1,29 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.api.RealmAssignmentResourceApi; +import dev.lions.user.manager.dto.realm.AuthorizedRealmsDTO; +import dev.lions.user.manager.dto.realm.RealmAccessCheckDTO; import dev.lions.user.manager.dto.realm.RealmAssignmentDTO; import dev.lions.user.manager.service.RealmAuthorizationService; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.*; import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.SecurityContext; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.util.List; /** * REST Resource pour la gestion des affectations de realms aux utilisateurs - * Permet le contrôle d'accès multi-tenant + * Implémente l'interface API commune. */ -@Path("/api/realm-assignments") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Tag(name = "Realm Assignments", description = "Gestion des affectations de realms (contrôle d'accès multi-tenant)") @Slf4j -public class RealmAssignmentResource { +@jakarta.enterprise.context.ApplicationScoped +@jakarta.ws.rs.Path("/api/realm-assignments") +public class RealmAssignmentResource implements RealmAssignmentResourceApi { @Inject RealmAuthorizationService realmAuthorizationService; @@ -40,172 +31,58 @@ public class RealmAssignmentResource { @Context SecurityContext securityContext; - // ==================== Endpoints de consultation ==================== - - @GET - @Operation(summary = "Lister toutes les affectations", description = "Liste toutes les affectations de realms") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des affectations"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response getAllAssignments() { + @Override + @RolesAllowed({ "admin" }) + public List getAllAssignments() { log.info("GET /api/realm-assignments - Récupération de toutes les affectations"); - - try { - List assignments = realmAuthorizationService.getAllAssignments(); - return Response.ok(assignments).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des affectations", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return realmAuthorizationService.getAllAssignments(); } - @GET - @Path("/user/{userId}") - @Operation(summary = "Affectations par utilisateur", description = "Liste les realms assignés à un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des affectations"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response getAssignmentsByUser( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public List getAssignmentsByUser(String userId) { log.info("GET /api/realm-assignments/user/{}", userId); - - try { - List assignments = realmAuthorizationService.getAssignmentsByUser(userId); - return Response.ok(assignments).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des affectations pour l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return realmAuthorizationService.getAssignmentsByUser(userId); } - @GET - @Path("/realm/{realmName}") - @Operation(summary = "Affectations par realm", description = "Liste les utilisateurs ayant accès à un realm") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des affectations"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response getAssignmentsByRealm( - @Parameter(description = "Nom du realm") @PathParam("realmName") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin" }) + public List getAssignmentsByRealm(String realmName) { log.info("GET /api/realm-assignments/realm/{}", realmName); - - try { - List assignments = realmAuthorizationService.getAssignmentsByRealm(realmName); - return Response.ok(assignments).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des affectations pour le realm {}", realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return realmAuthorizationService.getAssignmentsByRealm(realmName); } - @GET - @Path("/{assignmentId}") - @Operation(summary = "Récupérer une affectation", description = "Récupère une affectation par son ID") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Affectation trouvée"), - @APIResponse(responseCode = "404", description = "Affectation non trouvée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response getAssignmentById( - @Parameter(description = "ID de l'affectation") @PathParam("assignmentId") @NotBlank String assignmentId - ) { + @Override + @RolesAllowed({ "admin" }) + public RealmAssignmentDTO getAssignmentById(String assignmentId) { log.info("GET /api/realm-assignments/{}", assignmentId); - - try { - return realmAuthorizationService.getAssignmentById(assignmentId) - .map(assignment -> Response.ok(assignment).build()) - .orElse(Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Affectation non trouvée")) - .build()); - } catch (Exception e) { - log.error("Erreur lors de la récupération de l'affectation {}", assignmentId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return realmAuthorizationService.getAssignmentById(assignmentId) + .orElseThrow(() -> new RuntimeException("Affectation non trouvée")); // ExceptionMapper should + // handle/map to 404 } - // ==================== Endpoints de vérification ==================== - - @GET - @Path("/check") - @Operation(summary = "Vérifier l'accès", description = "Vérifie si un utilisateur peut gérer un realm") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Vérification effectuée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response canManageRealm( - @Parameter(description = "ID de l'utilisateur") @QueryParam("userId") @NotBlank String userId, - @Parameter(description = "Nom du realm") @QueryParam("realmName") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public RealmAccessCheckDTO canManageRealm(String userId, String realmName) { log.info("GET /api/realm-assignments/check - userId: {}, realmName: {}", userId, realmName); - - try { - boolean canManage = realmAuthorizationService.canManageRealm(userId, realmName); - return Response.ok(new CheckResponse(canManage, userId, realmName)).build(); - } catch (Exception e) { - log.error("Erreur lors de la vérification d'accès", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + boolean canManage = realmAuthorizationService.canManageRealm(userId, realmName); + return new RealmAccessCheckDTO(canManage, userId, realmName); } - @GET - @Path("/authorized-realms/{userId}") - @Operation(summary = "Realms autorisés", description = "Liste les realms qu'un utilisateur peut gérer") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des realms"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response getAuthorizedRealms( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public AuthorizedRealmsDTO getAuthorizedRealms(String userId) { log.info("GET /api/realm-assignments/authorized-realms/{}", userId); - - try { - List realms = realmAuthorizationService.getAuthorizedRealms(userId); - boolean isSuperAdmin = realmAuthorizationService.isSuperAdmin(userId); - return Response.ok(new AuthorizedRealmsResponse(realms, isSuperAdmin)).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des realms autorisés pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + List realms = realmAuthorizationService.getAuthorizedRealms(userId); + boolean isSuperAdmin = realmAuthorizationService.isSuperAdmin(userId); + return new AuthorizedRealmsDTO(realms, isSuperAdmin); } - // ==================== Endpoints de modification ==================== - - @POST - @Operation(summary = "Assigner un realm", description = "Assigne un realm à un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "201", description = "Affectation créée", - content = @Content(schema = @Schema(implementation = RealmAssignmentDTO.class))), - @APIResponse(responseCode = "400", description = "Données invalides"), - @APIResponse(responseCode = "409", description = "Affectation existe déjà"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) + @Override + @RolesAllowed({ "admin" }) public Response assignRealmToUser(@Valid @NotNull RealmAssignmentDTO assignment) { log.info("POST /api/realm-assignments - Assignation du realm {} à l'utilisateur {}", - assignment.getRealmName(), assignment.getUserId()); + assignment.getRealmName(), assignment.getUserId()); try { // Ajouter l'utilisateur qui fait l'assignation @@ -217,190 +94,48 @@ public class RealmAssignmentResource { return Response.status(Response.Status.CREATED).entity(createdAssignment).build(); } catch (IllegalArgumentException e) { log.warn("Données invalides lors de l'assignation: {}", e.getMessage()); + // Need to return 409 or 400 manually since this method returns Response return Response.status(Response.Status.CONFLICT) - .entity(new ErrorResponse(e.getMessage())) - .build(); + .entity(new dev.lions.user.manager.dto.common.ApiErrorDTO(e.getMessage())) + .build(); } catch (Exception e) { log.error("Erreur lors de l'assignation du realm", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + throw new RuntimeException(e); } } - @DELETE - @Path("/user/{userId}/realm/{realmName}") - @Operation(summary = "Révoquer un realm", description = "Retire l'accès d'un utilisateur à un realm") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Affectation révoquée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response revokeRealmFromUser( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId, - @Parameter(description = "Nom du realm") @PathParam("realmName") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin" }) + public void revokeRealmFromUser(String userId, String realmName) { log.info("DELETE /api/realm-assignments/user/{}/realm/{}", userId, realmName); - - try { - realmAuthorizationService.revokeRealmFromUser(userId, realmName); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la révocation du realm {} pour {}", realmName, userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + realmAuthorizationService.revokeRealmFromUser(userId, realmName); } - @DELETE - @Path("/user/{userId}") - @Operation(summary = "Révoquer tous les realms", description = "Retire tous les accès d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Affectations révoquées"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response revokeAllRealmsFromUser( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId - ) { + @Override + @RolesAllowed({ "admin" }) + public void revokeAllRealmsFromUser(String userId) { log.info("DELETE /api/realm-assignments/user/{}", userId); - - try { - realmAuthorizationService.revokeAllRealmsFromUser(userId); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la révocation de tous les realms pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + realmAuthorizationService.revokeAllRealmsFromUser(userId); } - @PUT - @Path("/{assignmentId}/deactivate") - @Operation(summary = "Désactiver une affectation", description = "Désactive une affectation sans la supprimer") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Affectation désactivée"), - @APIResponse(responseCode = "404", description = "Affectation non trouvée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response deactivateAssignment( - @Parameter(description = "ID de l'affectation") @PathParam("assignmentId") @NotBlank String assignmentId - ) { + @Override + @RolesAllowed({ "admin" }) + public void deactivateAssignment(String assignmentId) { log.info("PUT /api/realm-assignments/{}/deactivate", assignmentId); - - try { - realmAuthorizationService.deactivateAssignment(assignmentId); - return Response.noContent().build(); - } catch (IllegalArgumentException e) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } catch (Exception e) { - log.error("Erreur lors de la désactivation de l'affectation {}", assignmentId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + realmAuthorizationService.deactivateAssignment(assignmentId); } - @PUT - @Path("/{assignmentId}/activate") - @Operation(summary = "Activer une affectation", description = "Réactive une affectation") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Affectation activée"), - @APIResponse(responseCode = "404", description = "Affectation non trouvée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response activateAssignment( - @Parameter(description = "ID de l'affectation") @PathParam("assignmentId") @NotBlank String assignmentId - ) { + @Override + @RolesAllowed({ "admin" }) + public void activateAssignment(String assignmentId) { log.info("PUT /api/realm-assignments/{}/activate", assignmentId); - - try { - realmAuthorizationService.activateAssignment(assignmentId); - return Response.noContent().build(); - } catch (IllegalArgumentException e) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } catch (Exception e) { - log.error("Erreur lors de l'activation de l'affectation {}", assignmentId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + realmAuthorizationService.activateAssignment(assignmentId); } - @PUT - @Path("/super-admin/{userId}") - @Operation(summary = "Définir super admin", description = "Définit ou retire le statut de super admin") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Statut modifié"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response setSuperAdmin( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId, - @Parameter(description = "Super admin (true/false)") @QueryParam("superAdmin") @NotNull Boolean superAdmin - ) { + @Override + @RolesAllowed({ "admin" }) + public void setSuperAdmin(String userId, @NotNull Boolean superAdmin) { log.info("PUT /api/realm-assignments/super-admin/{} - superAdmin: {}", userId, superAdmin); - - try { - realmAuthorizationService.setSuperAdmin(userId, superAdmin); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la modification du statut super admin pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - // ==================== Classes internes pour les réponses ==================== - - @Schema(description = "Réponse d'erreur") - public static class ErrorResponse { - @Schema(description = "Message d'erreur") - public String message; - - public ErrorResponse(String message) { - this.message = message; - } - } - - @Schema(description = "Réponse de vérification d'accès") - public static class CheckResponse { - @Schema(description = "L'utilisateur peut gérer le realm") - public boolean canManage; - - @Schema(description = "ID de l'utilisateur") - public String userId; - - @Schema(description = "Nom du realm") - public String realmName; - - public CheckResponse(boolean canManage, String userId, String realmName) { - this.canManage = canManage; - this.userId = userId; - this.realmName = realmName; - } - } - - @Schema(description = "Réponse des realms autorisés") - public static class AuthorizedRealmsResponse { - @Schema(description = "Liste des realms (vide si super admin)") - public List realms; - - @Schema(description = "L'utilisateur est super admin") - public boolean isSuperAdmin; - - public AuthorizedRealmsResponse(List realms, boolean isSuperAdmin) { - this.realms = realms; - this.isSuperAdmin = isSuperAdmin; - } + realmAuthorizationService.setSuperAdmin(userId, superAdmin); } } diff --git a/src/main/java/dev/lions/user/manager/resource/RealmResource.java b/src/main/java/dev/lions/user/manager/resource/RealmResource.java index 107adb1..3bbb2fc 100644 --- a/src/main/java/dev/lions/user/manager/resource/RealmResource.java +++ b/src/main/java/dev/lions/user/manager/resource/RealmResource.java @@ -1,29 +1,22 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.api.RealmResourceApi; import dev.lions.user.manager.client.KeycloakAdminClient; import io.quarkus.security.identity.SecurityIdentity; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.util.List; /** * Ressource REST pour la gestion des realms Keycloak + * Implémente l'interface API commune. */ -@Path("/api/realms") -@Tag(name = "Realms", description = "Gestion des realms Keycloak") @Slf4j -public class RealmResource { +@jakarta.enterprise.context.ApplicationScoped +@jakarta.ws.rs.Path("/api/realms") +public class RealmResource implements RealmResourceApi { @Inject KeycloakAdminClient keycloakAdminClient; @@ -31,47 +24,33 @@ public class RealmResource { @Inject SecurityIdentity securityIdentity; - @GET - @Path("/list") - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Lister tous les realms", description = "Récupère la liste de tous les realms disponibles dans Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des realms"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager", "user_viewer", "role_manager", "role_viewer"}) - public Response getAllRealms() { + @Override + @RolesAllowed({ "admin", "user_manager", "user_viewer", "role_manager", "role_viewer" }) + public List getAllRealms() { log.info("GET /api/realms/list"); try { List realms = keycloakAdminClient.getAllRealms(); log.info("Récupération réussie: {} realms trouvés", realms.size()); - return Response.ok(realms).build(); + return realms; } catch (Exception e) { log.error("Erreur lors de la récupération des realms", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse("Erreur lors de la récupération des realms: " + e.getMessage())) - .build(); + throw new RuntimeException("Erreur lors de la récupération des realms: " + e.getMessage(), e); } } - /** - * Classe interne pour les réponses d'erreur - */ - public static class ErrorResponse { - private String message; + @Override + @RolesAllowed({ "admin", "user_manager", "role_manager", "role_viewer" }) + public List getRealmClients(String realmName) { + log.info("GET /api/realms/{}/clients", realmName); - public ErrorResponse(String message) { - this.message = message; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; + try { + List clients = keycloakAdminClient.getRealmClients(realmName); + log.info("Récupération réussie: {} clients trouvés pour le realm {}", clients.size(), realmName); + return clients; + } catch (Exception e) { + log.error("Erreur lors de la récupération des clients du realm {}", realmName, e); + throw new RuntimeException("Erreur lors de la récupération des clients: " + e.getMessage(), e); } } } - diff --git a/src/main/java/dev/lions/user/manager/resource/RoleResource.java b/src/main/java/dev/lions/user/manager/resource/RoleResource.java index cf55525..c1ed11f 100644 --- a/src/main/java/dev/lions/user/manager/resource/RoleResource.java +++ b/src/main/java/dev/lions/user/manager/resource/RoleResource.java @@ -1,62 +1,50 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.api.RoleResourceApi; +import dev.lions.user.manager.dto.common.ApiErrorDTO; import dev.lions.user.manager.dto.role.RoleAssignmentDTO; +import dev.lions.user.manager.dto.role.RoleAssignmentRequestDTO; import dev.lions.user.manager.dto.role.RoleDTO; import dev.lions.user.manager.enums.role.TypeRole; import dev.lions.user.manager.service.RoleService; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * REST Resource pour la gestion des rôles Keycloak - * Endpoints pour les rôles realm, rôles client, et attributions + * Implémente l'interface API commune. + * Annotation explicite des méthodes pour éviter les problèmes d'héritage JAX-RS + * dans Quarkus. */ -@Path("/api/roles") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Tag(name = "Roles", description = "Gestion des rôles Keycloak (realm et client)") @Slf4j -public class RoleResource { +@jakarta.enterprise.context.ApplicationScoped +@Path("/api/roles") +public class RoleResource implements RoleResourceApi { @Inject RoleService roleService; // ==================== Endpoints Realm Roles ==================== + @Override @POST @Path("/realm") - @Operation(summary = "Créer un rôle realm", description = "Crée un nouveau rôle au niveau du realm") - @APIResponses({ - @APIResponse(responseCode = "201", description = "Rôle créé", - content = @Content(schema = @Schema(implementation = RoleDTO.class))), - @APIResponse(responseCode = "400", description = "Données invalides"), - @APIResponse(responseCode = "409", description = "Rôle existe déjà"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) + @RolesAllowed({ "admin", "role_manager" }) public Response createRealmRole( - @Valid @NotNull RoleDTO roleDTO, - @QueryParam("realm") @NotBlank String realmName - ) { + @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") String realmName) { log.info("POST /api/roles/realm - Création du rôle realm: {} dans le realm: {}", - roleDTO.getName(), realmName); + roleDTO.getName(), realmName); try { RoleDTO createdRole = roleService.createRealmRole(roleDTO, realmName); @@ -64,530 +52,239 @@ public class RoleResource { } catch (IllegalArgumentException e) { log.warn("Données invalides lors de la création du rôle: {}", e.getMessage()); return Response.status(Response.Status.CONFLICT) - .entity(new ErrorResponse(e.getMessage())) - .build(); + .entity(new ApiErrorDTO(e.getMessage())) + .build(); } catch (Exception e) { log.error("Erreur lors de la création du rôle realm", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + throw new RuntimeException(e); } } + @Override @GET @Path("/realm/{roleName}") - @Operation(summary = "Récupérer un rôle realm par nom", description = "Récupère les détails d'un rôle realm") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Rôle trouvé", - content = @Content(schema = @Schema(implementation = RoleDTO.class))), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getRealmRole( - @Parameter(description = "Nom du rôle") @PathParam("roleName") @NotBlank String roleName, - @Parameter(description = "Nom du realm") @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public RoleDTO getRealmRole(@PathParam("roleName") String roleName, @QueryParam("realm") String realmName) { log.info("GET /api/roles/realm/{} - realm: {}", roleName, realmName); - - try { - return roleService.getRoleByName(roleName, realmName, dev.lions.user.manager.enums.role.TypeRole.REALM_ROLE, null) - .map(role -> Response.ok(role).build()) - .orElse(Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle non trouvé")) - .build()); - } catch (Exception e) { - log.error("Erreur lors de la récupération du rôle realm {}", roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null) + .orElseThrow(() -> new RuntimeException("Rôle non trouvé")); } + @Override @GET @Path("/realm") - @Operation(summary = "Lister tous les rôles realm", description = "Liste tous les rôles du realm") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des rôles"), - @APIResponse(responseCode = "400", description = "Realm invalide ou inexistant"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getAllRealmRoles( - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public List getAllRealmRoles(@QueryParam("realm") String realmName) { log.info("GET /api/roles/realm - realm: {}", realmName); - - try { - List roles = roleService.getAllRealmRoles(realmName); - return Response.ok(roles).build(); - } catch (IllegalArgumentException e) { - log.warn("Realm invalide ou inexistant: {}", e.getMessage()); - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des rôles realm", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getAllRealmRoles(realmName); } + @Override @PUT @Path("/realm/{roleName}") - @Operation(summary = "Mettre à jour un rôle realm", description = "Met à jour les informations d'un rôle realm") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Rôle mis à jour", - content = @Content(schema = @Schema(implementation = RoleDTO.class))), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response updateRealmRole( - @PathParam("roleName") @NotBlank String roleName, - @Valid @NotNull RoleDTO roleDTO, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager" }) + public RoleDTO updateRealmRole(@PathParam("roleName") String roleName, @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") String realmName) { log.info("PUT /api/roles/realm/{} - realm: {}", roleName, realmName); - try { - // Récupérer l'ID du rôle par son nom - Optional existingRole = roleService.getRoleByName(roleName, realmName, - dev.lions.user.manager.enums.role.TypeRole.REALM_ROLE, null); - if (existingRole.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle non trouvé")) - .build(); - } - - RoleDTO updatedRole = roleService.updateRole(existingRole.get().getId(), roleDTO, realmName, - dev.lions.user.manager.enums.role.TypeRole.REALM_ROLE, null); - return Response.ok(updatedRole).build(); - } catch (Exception e) { - log.error("Erreur lors de la mise à jour du rôle realm {}", roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + Optional existingRole = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); + if (existingRole.isEmpty()) { + throw new RuntimeException("Rôle non trouvé"); } + + return roleService.updateRole(existingRole.get().getId(), roleDTO, realmName, TypeRole.REALM_ROLE, null); } + @Override @DELETE @Path("/realm/{roleName}") - @Operation(summary = "Supprimer un rôle realm", description = "Supprime un rôle realm") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Rôle supprimé"), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response deleteRealmRole( - @PathParam("roleName") @NotBlank String roleName, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin" }) + public void deleteRealmRole(@PathParam("roleName") String roleName, @QueryParam("realm") String realmName) { log.info("DELETE /api/roles/realm/{} - realm: {}", roleName, realmName); - try { - // Récupérer l'ID du rôle par son nom - Optional existingRole = roleService.getRoleByName(roleName, realmName, - dev.lions.user.manager.enums.role.TypeRole.REALM_ROLE, null); - if (existingRole.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle non trouvé")) - .build(); - } - - roleService.deleteRole(existingRole.get().getId(), realmName, - dev.lions.user.manager.enums.role.TypeRole.REALM_ROLE, null); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la suppression du rôle realm {}", roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + Optional existingRole = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); + if (existingRole.isEmpty()) { + throw new RuntimeException("Rôle non trouvé"); } + + roleService.deleteRole(existingRole.get().getId(), realmName, TypeRole.REALM_ROLE, null); } // ==================== Endpoints Client Roles ==================== + @Override @POST @Path("/client/{clientId}") - @Operation(summary = "Créer un rôle client", description = "Crée un nouveau rôle pour un client spécifique") - @APIResponses({ - @APIResponse(responseCode = "201", description = "Rôle créé", - content = @Content(schema = @Schema(implementation = RoleDTO.class))), - @APIResponse(responseCode = "400", description = "Données invalides"), - @APIResponse(responseCode = "409", description = "Rôle existe déjà"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response createClientRole( - @PathParam("clientId") @NotBlank String clientId, - @Valid @NotNull RoleDTO roleDTO, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager" }) + public Response createClientRole(@PathParam("clientId") String clientId, @Valid @NotNull RoleDTO roleDTO, + @QueryParam("realm") String realmName) { log.info("POST /api/roles/client/{} - Création du rôle client dans le realm: {}", - clientId, realmName); + clientId, realmName); try { - RoleDTO createdRole = roleService.createClientRole(roleDTO, realmName, clientId); + RoleDTO createdRole = roleService.createClientRole(roleDTO, clientId, realmName); return Response.status(Response.Status.CREATED).entity(createdRole).build(); } catch (IllegalArgumentException e) { log.warn("Données invalides lors de la création du rôle client: {}", e.getMessage()); return Response.status(Response.Status.CONFLICT) - .entity(new ErrorResponse(e.getMessage())) - .build(); + .entity(new ApiErrorDTO(e.getMessage())) + .build(); } catch (Exception e) { log.error("Erreur lors de la création du rôle client", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + throw new RuntimeException(e); } } + @Override @GET @Path("/client/{clientId}/{roleName}") - @Operation(summary = "Récupérer un rôle client par nom", description = "Récupère les détails d'un rôle client") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Rôle trouvé", - content = @Content(schema = @Schema(implementation = RoleDTO.class))), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getClientRole( - @PathParam("clientId") @NotBlank String clientId, - @PathParam("roleName") @NotBlank String roleName, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public RoleDTO getClientRole(@PathParam("clientId") String clientId, @PathParam("roleName") String roleName, + @QueryParam("realm") String realmName) { log.info("GET /api/roles/client/{}/{} - realm: {}", clientId, roleName, realmName); - - try { - return roleService.getRoleByName(roleName, realmName, - dev.lions.user.manager.enums.role.TypeRole.CLIENT_ROLE, clientId) - .map(role -> Response.ok(role).build()) - .orElse(Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle client non trouvé")) - .build()); - } catch (Exception e) { - log.error("Erreur lors de la récupération du rôle client {}/{}", clientId, roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getRoleByName(roleName, realmName, TypeRole.CLIENT_ROLE, clientId) + .orElseThrow(() -> new RuntimeException("Rôle client non trouvé")); } + @Override @GET @Path("/client/{clientId}") - @Operation(summary = "Lister tous les rôles d'un client", description = "Liste tous les rôles d'un client spécifique") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des rôles"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getAllClientRoles( - @PathParam("clientId") @NotBlank String clientId, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public List getAllClientRoles(@PathParam("clientId") String clientId, + @QueryParam("realm") String realmName) { log.info("GET /api/roles/client/{} - realm: {}", clientId, realmName); - - try { - List roles = roleService.getAllClientRoles(realmName, clientId); - return Response.ok(roles).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des rôles du client {}", clientId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getAllClientRoles(realmName, clientId); } + @Override @DELETE @Path("/client/{clientId}/{roleName}") - @Operation(summary = "Supprimer un rôle client", description = "Supprime un rôle d'un client") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Rôle supprimé"), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response deleteClientRole( - @PathParam("clientId") @NotBlank String clientId, - @PathParam("roleName") @NotBlank String roleName, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin" }) + public void deleteClientRole(@PathParam("clientId") String clientId, @PathParam("roleName") String roleName, + @QueryParam("realm") String realmName) { log.info("DELETE /api/roles/client/{}/{} - realm: {}", clientId, roleName, realmName); - try { - // Récupérer l'ID du rôle par son nom - Optional existingRole = roleService.getRoleByName(roleName, realmName, - dev.lions.user.manager.enums.role.TypeRole.CLIENT_ROLE, clientId); - if (existingRole.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle client non trouvé")) - .build(); - } - - roleService.deleteRole(existingRole.get().getId(), realmName, - dev.lions.user.manager.enums.role.TypeRole.CLIENT_ROLE, clientId); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la suppression du rôle client {}/{}", clientId, roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + Optional existingRole = roleService.getRoleByName(roleName, realmName, TypeRole.CLIENT_ROLE, clientId); + if (existingRole.isEmpty()) { + throw new RuntimeException("Rôle client non trouvé"); } + + roleService.deleteRole(existingRole.get().getId(), realmName, TypeRole.CLIENT_ROLE, clientId); } // ==================== Endpoints Attribution de rôles ==================== + @Override @POST @Path("/assign/realm/{userId}") - @Operation(summary = "Attribuer des rôles realm à un utilisateur", description = "Assigne un ou plusieurs rôles realm à un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Rôles attribués"), - @APIResponse(responseCode = "404", description = "Utilisateur ou rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response assignRealmRoles( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @NotNull RoleAssignmentRequest request - ) { - log.info("POST /api/roles/assign/realm/{} - Attribution de {} rôles", userId, request.roleNames.size()); + @RolesAllowed({ "admin", "role_manager" }) + public void assignRealmRoles(@PathParam("userId") String userId, @QueryParam("realm") String realmName, + @NotNull RoleAssignmentRequestDTO request) { + log.info("POST /api/roles/assign/realm/{} - Attribution de {} rôles", userId, request.getRoleNames().size()); - try { - RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() + RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() .userId(userId) - .roleNames(request.roleNames) + .roleNames(request.getRoleNames()) .typeRole(TypeRole.REALM_ROLE) .realmName(realmName) .build(); - roleService.assignRolesToUser(assignment); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de l'attribution des rôles realm à l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + roleService.assignRolesToUser(assignment); } + @Override @POST @Path("/revoke/realm/{userId}") - @Operation(summary = "Révoquer des rôles realm d'un utilisateur", description = "Révoque un ou plusieurs rôles realm d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Rôles révoqués"), - @APIResponse(responseCode = "404", description = "Utilisateur ou rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response revokeRealmRoles( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @NotNull RoleAssignmentRequest request - ) { - log.info("POST /api/roles/revoke/realm/{} - Révocation de {} rôles", userId, request.roleNames.size()); + @RolesAllowed({ "admin", "role_manager" }) + public void revokeRealmRoles(@PathParam("userId") String userId, @QueryParam("realm") String realmName, + @NotNull RoleAssignmentRequestDTO request) { + log.info("POST /api/roles/revoke/realm/{} - Révocation de {} rôles", userId, request.getRoleNames().size()); - try { - RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() + RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() .userId(userId) - .roleNames(request.roleNames) + .roleNames(request.getRoleNames()) .typeRole(TypeRole.REALM_ROLE) .realmName(realmName) .build(); - roleService.revokeRolesFromUser(assignment); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la révocation des rôles realm de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + roleService.revokeRolesFromUser(assignment); } + @Override @POST @Path("/assign/client/{clientId}/{userId}") - @Operation(summary = "Attribuer des rôles client à un utilisateur", description = "Assigne un ou plusieurs rôles client à un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Rôles attribués"), - @APIResponse(responseCode = "404", description = "Utilisateur, client ou rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response assignClientRoles( - @PathParam("clientId") @NotBlank String clientId, - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @NotNull RoleAssignmentRequest request - ) { + @RolesAllowed({ "admin", "role_manager" }) + public void assignClientRoles(@PathParam("clientId") String clientId, @PathParam("userId") String userId, + @QueryParam("realm") String realmName, + @NotNull RoleAssignmentRequestDTO request) { log.info("POST /api/roles/assign/client/{}/{} - Attribution de {} rôles client", - clientId, userId, request.roleNames.size()); + clientId, userId, request.getRoleNames().size()); - try { - RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() + RoleAssignmentDTO assignment = RoleAssignmentDTO.builder() .userId(userId) - .roleNames(request.roleNames) + .roleNames(request.getRoleNames()) .typeRole(TypeRole.CLIENT_ROLE) .realmName(realmName) .clientName(clientId) .build(); - roleService.assignRolesToUser(assignment); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de l'attribution des rôles client à l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + roleService.assignRolesToUser(assignment); } + @Override @GET @Path("/user/realm/{userId}") - @Operation(summary = "Récupérer les rôles realm d'un utilisateur", description = "Liste tous les rôles realm d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des rôles"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getUserRealmRoles( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public List getUserRealmRoles(@PathParam("userId") String userId, @QueryParam("realm") String realmName) { log.info("GET /api/roles/user/realm/{} - realm: {}", userId, realmName); - - try { - List roles = roleService.getUserRealmRoles(userId, realmName); - return Response.ok(roles).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des rôles realm de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getUserRealmRoles(userId, realmName); } + @Override @GET @Path("/user/client/{clientId}/{userId}") - @Operation(summary = "Récupérer les rôles client d'un utilisateur", description = "Liste tous les rôles client d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des rôles"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getUserClientRoles( - @PathParam("clientId") @NotBlank String clientId, - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public List getUserClientRoles(@PathParam("clientId") String clientId, @PathParam("userId") String userId, + @QueryParam("realm") String realmName) { log.info("GET /api/roles/user/client/{}/{} - realm: {}", clientId, userId, realmName); - - try { - List roles = roleService.getUserClientRoles(userId, clientId, realmName); - return Response.ok(roles).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des rôles client de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return roleService.getUserClientRoles(userId, clientId, realmName); } // ==================== Endpoints Rôles composites ==================== + @Override @POST @Path("/composite/{roleName}/add") - @Operation(summary = "Ajouter des rôles composites", description = "Ajoute des rôles composites à un rôle") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Composites ajoutés"), - @APIResponse(responseCode = "404", description = "Rôle non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager"}) - public Response addComposites( - @PathParam("roleName") @NotBlank String roleName, - @QueryParam("realm") @NotBlank String realmName, - @NotNull RoleAssignmentRequest request - ) { - log.info("POST /api/roles/composite/{}/add - Ajout de {} composites", roleName, request.roleNames.size()); + @RolesAllowed({ "admin", "role_manager" }) + public void addComposites(@PathParam("roleName") String roleName, @QueryParam("realm") String realmName, + @NotNull RoleAssignmentRequestDTO request) { + log.info("POST /api/roles/composite/{}/add - Ajout de {} composites", roleName, request.getRoleNames().size()); - try { - // Récupérer l'ID du rôle parent par son nom - Optional parentRole = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); - if (parentRole.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle parent non trouvé")) - .build(); - } - - // Convertir les noms de rôles en IDs - List childRoleIds = request.roleNames.stream() + Optional parentRole = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); + if (parentRole.isEmpty()) { + throw new RuntimeException("Rôle parent non trouvé"); + } + + List childRoleIds = request.getRoleNames().stream() .map(name -> { Optional role = roleService.getRoleByName(name, realmName, TypeRole.REALM_ROLE, null); return role.map(RoleDTO::getId).orElse(null); }) .filter(id -> id != null) - .collect(java.util.stream.Collectors.toList()); - - roleService.addCompositeRoles(parentRole.get().getId(), childRoleIds, realmName, TypeRole.REALM_ROLE, null); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de l'ajout des composites au rôle {}", roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + .collect(Collectors.toList()); + + roleService.addCompositeRoles(parentRole.get().getId(), childRoleIds, realmName, TypeRole.REALM_ROLE, null); } + @Override @GET @Path("/composite/{roleName}") - @Operation(summary = "Récupérer les rôles composites", description = "Liste tous les rôles composites d'un rôle") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des composites"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "role_manager", "role_viewer"}) - public Response getComposites( - @PathParam("roleName") @NotBlank String roleName, - @QueryParam("realm") @NotBlank String realmName - ) { + @RolesAllowed({ "admin", "role_manager", "role_viewer" }) + public List getComposites(@PathParam("roleName") String roleName, @QueryParam("realm") String realmName) { log.info("GET /api/roles/composite/{} - realm: {}", roleName, realmName); - try { - // Récupérer l'ID du rôle par son nom - Optional role = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); - if (role.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Rôle non trouvé")) - .build(); - } - - List composites = roleService.getCompositeRoles(role.get().getId(), realmName, TypeRole.REALM_ROLE, null); - return Response.ok(composites).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des composites du rôle {}", roleName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + Optional role = roleService.getRoleByName(roleName, realmName, TypeRole.REALM_ROLE, null); + if (role.isEmpty()) { + throw new RuntimeException("Rôle non trouvé"); } - } - // ==================== DTOs internes ==================== - - @Schema(description = "Requête d'attribution/révocation de rôles") - public static class RoleAssignmentRequest { - @Schema(description = "Liste des noms de rôles", required = true) - public List roleNames; - } - - @Schema(description = "Réponse d'erreur") - public static class ErrorResponse { - @Schema(description = "Message d'erreur") - public String message; - - public ErrorResponse(String message) { - this.message = message; - } + return roleService.getCompositeRoles(role.get().getId(), realmName, TypeRole.REALM_ROLE, null); } } diff --git a/src/main/java/dev/lions/user/manager/resource/SyncResource.java b/src/main/java/dev/lions/user/manager/resource/SyncResource.java index db8c9fa..ffa0832 100644 --- a/src/main/java/dev/lions/user/manager/resource/SyncResource.java +++ b/src/main/java/dev/lions/user/manager/resource/SyncResource.java @@ -1,318 +1,166 @@ package dev.lions.user.manager.resource; -import dev.lions.user.manager.dto.role.RoleDTO; -import dev.lions.user.manager.dto.user.UserDTO; +import dev.lions.user.manager.api.SyncResourceApi; +import dev.lions.user.manager.dto.sync.HealthStatusDTO; +import dev.lions.user.manager.dto.sync.SyncConsistencyDTO; +import dev.lions.user.manager.dto.sync.SyncHistoryDTO; +import dev.lions.user.manager.dto.sync.SyncResultDTO; import dev.lions.user.manager.service.SyncService; +import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; -import jakarta.validation.constraints.NotBlank; -import jakarta.ws.rs.*; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; -import java.util.List; import java.util.Map; /** - * REST Resource pour la synchronisation avec Keycloak + * REST Resource pour la synchronisation avec Keycloak. + * Suit le même pattern que AuditResource : les annotations JAX-RS des méthodes + * héritées de l'interface ne sont PAS répétées ici (conformité RESTEasy Reactive). */ -@Path("/api/sync") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Tag(name = "Sync", description = "Synchronisation avec Keycloak et health checks") @Slf4j -public class SyncResource { +@jakarta.enterprise.context.ApplicationScoped +@jakarta.ws.rs.Path("/api/sync") +@jakarta.ws.rs.Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) +@jakarta.ws.rs.Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON) +public class SyncResource implements SyncResourceApi { @Inject SyncService syncService; - @POST - @Path("/users/{realmName}") - @Operation(summary = "Synchroniser les utilisateurs", description = "Synchronise tous les utilisateurs depuis Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Utilisateurs synchronisés"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response syncUsers( - @Parameter(description = "Nom du realm") @PathParam("realmName") @NotBlank String realmName - ) { - log.info("POST /api/sync/users/{} - Synchronisation des utilisateurs", realmName); + @GET + @Path("/ping") + @PermitAll + public String ping() { + return "{\"status\":\"pong\",\"resource\":\"SyncResource\"}"; + } + @Override + @PermitAll + public HealthStatusDTO checkKeycloakHealth() { + log.info("REST: checkKeycloakHealth sur /api/sync/health/keycloak"); + try { + boolean available = syncService.isKeycloakAvailable(); + Map details = syncService.getKeycloakHealthInfo(); + return HealthStatusDTO.builder() + .keycloakAccessible(available) + .overallHealthy(available) + .keycloakVersion((String) details.getOrDefault("version", "Unknown")) + .timestamp(System.currentTimeMillis()) + .build(); + } catch (Exception e) { + log.error("Erreur lors du check health keycloak", e); + return HealthStatusDTO.builder() + .overallHealthy(false) + .errorMessage("Erreur: " + e.getMessage()) + .timestamp(System.currentTimeMillis()) + .build(); + } + } + + @Override + @RolesAllowed({ "admin", "sync_manager" }) + public SyncResultDTO syncUsers(String realmName) { + log.info("REST: syncUsers pour le realm: {}", realmName); + long start = System.currentTimeMillis(); try { int count = syncService.syncUsersFromRealm(realmName); - return Response.ok(new SyncUsersResponse(count, null)).build(); - } catch (Exception e) { - log.error("Erreur lors de la synchronisation des utilisateurs du realm {}", realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - @POST - @Path("/roles/realm/{realmName}") - @Operation(summary = "Synchroniser les rôles realm", description = "Synchronise tous les rôles realm depuis Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Rôles realm synchronisés"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response syncRealmRoles( - @PathParam("realmName") @NotBlank String realmName - ) { - log.info("POST /api/sync/roles/realm/{} - Synchronisation des rôles realm", realmName); - - try { - int count = syncService.syncRolesFromRealm(realmName); - return Response.ok(new SyncRolesResponse(count, null)).build(); - } catch (Exception e) { - log.error("Erreur lors de la synchronisation des rôles realm du realm {}", realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - @POST - @Path("/roles/client/{clientId}/{realmName}") - @Operation(summary = "Synchroniser les rôles client", description = "Synchronise tous les rôles d'un client depuis Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Rôles client synchronisés"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response syncClientRoles( - @PathParam("clientId") @NotBlank String clientId, - @PathParam("realmName") @NotBlank String realmName - ) { - log.info("POST /api/sync/roles/client/{}/{} - Synchronisation des rôles client", - clientId, realmName); - - try { - // Note: syncRolesFromRealm synchronise tous les rôles realm, pas les rôles client spécifiques - // Pour les rôles client, on synchronise tous les rôles du realm (incluant les rôles client) - int count = syncService.syncRolesFromRealm(realmName); - return Response.ok(new SyncRolesResponse(count, null)).build(); - } catch (Exception e) { - log.error("Erreur lors de la synchronisation des rôles client du client {} (realm: {})", - clientId, realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - @POST - @Path("/all/{realmName}") - @Operation(summary = "Synchronisation complète", description = "Synchronise utilisateurs et rôles depuis Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Synchronisation complète effectuée"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response syncAll( - @PathParam("realmName") @NotBlank String realmName - ) { - log.info("POST /api/sync/all/{} - Synchronisation complète", realmName); - - try { - Map result = syncService.forceSyncRealm(realmName); - return Response.ok(result).build(); - } catch (Exception e) { - log.error("Erreur lors de la synchronisation complète du realm {}", realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - @GET - @Path("/health") - @Operation(summary = "Vérifier la santé de Keycloak", description = "Retourne le statut de santé de Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Statut de santé"), - @APIResponse(responseCode = "503", description = "Keycloak non accessible") - }) - @RolesAllowed({"admin", "sync_manager", "auditor"}) - public Response checkHealth() { - log.info("GET /api/sync/health - Vérification de la santé de Keycloak"); - - try { - boolean healthy = syncService.isKeycloakAvailable(); - if (healthy) { - return Response.ok(new HealthCheckResponse(true, "Keycloak est accessible")).build(); - } else { - return Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity(new HealthCheckResponse(false, "Keycloak n'est pas accessible")) + return SyncResultDTO.builder() + .success(true) + .usersCount(count) + .realmName(realmName) + .startTime(start) + .endTime(System.currentTimeMillis()) .build(); - } } catch (Exception e) { - log.error("Erreur lors de la vérification de santé de Keycloak", e); - return Response.status(Response.Status.SERVICE_UNAVAILABLE) - .entity(new HealthCheckResponse(false, e.getMessage())) - .build(); + log.error("Erreur lors de la synchro users realm {}", realmName, e); + return SyncResultDTO.builder() + .success(false) + .errorMessage(e.getMessage()) + .realmName(realmName) + .startTime(start) + .endTime(System.currentTimeMillis()) + .build(); } } - @GET - @Path("/health/detailed") - @Operation(summary = "Statut de santé détaillé", description = "Retourne le statut de santé détaillé de Keycloak") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Statut détaillé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response getDetailedHealthStatus() { - log.info("GET /api/sync/health/detailed - Statut de santé détaillé"); - + @Override + @RolesAllowed({ "admin", "sync_manager" }) + public SyncResultDTO syncRoles(String realmName, String clientName) { + log.info("REST: syncRoles pour le realm: {}, client: {}", realmName, clientName); + long start = System.currentTimeMillis(); try { - Map status = syncService.getKeycloakHealthInfo(); - return Response.ok(status).build(); // status est maintenant une Map + int count = syncService.syncRolesFromRealm(realmName); + return SyncResultDTO.builder() + .success(true) + .realmRolesCount(count) + .realmName(realmName) + .startTime(start) + .endTime(System.currentTimeMillis()) + .build(); } catch (Exception e) { - log.error("Erreur lors de la récupération du statut de santé détaillé", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + log.error("Erreur lors de la synchro roles realm {}", realmName, e); + return SyncResultDTO.builder() + .success(false) + .errorMessage(e.getMessage()) + .realmName(realmName) + .startTime(start) + .endTime(System.currentTimeMillis()) + .build(); } } - @GET - @Path("/check/realm/{realmName}") - @Operation(summary = "Vérifier l'existence d'un realm", description = "Vérifie si un realm existe") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Résultat de la vérification"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response checkRealmExists( - @PathParam("realmName") @NotBlank String realmName - ) { - log.info("GET /api/sync/check/realm/{} - Vérification de l'existence", realmName); - + @Override + @RolesAllowed({ "admin", "sync_manager" }) + public SyncConsistencyDTO checkDataConsistency(String realmName) { + log.info("REST: checkDataConsistency pour realm: {}", realmName); try { - // Vérifier l'existence du realm en essayant de synchroniser (si ça marche, le realm existe) - boolean exists = false; - try { - syncService.syncUsersFromRealm(realmName); - exists = true; - } catch (Exception e) { - exists = false; - } - return Response.ok(new ExistsCheckResponse(exists, "realm", realmName)).build(); + Map report = syncService.checkDataConsistency(realmName); + return SyncConsistencyDTO.builder() + .realmName((String) report.get("realmName")) + .status((String) report.get("status")) + .usersKeycloakCount((Integer) report.get("usersKeycloakCount")) + .usersLocalCount((Integer) report.get("usersLocalCount")) + .error((String) report.get("error")) + .build(); } catch (Exception e) { - log.error("Erreur lors de la vérification du realm {}", realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + log.error("Erreur checkDataConsistency realm {}", realmName, e); + return SyncConsistencyDTO.builder() + .realmName(realmName) + .status("ERROR") + .error(e.getMessage()) + .build(); } } - @GET - @Path("/check/user/{userId}") - @Operation(summary = "Vérifier l'existence d'un utilisateur", description = "Vérifie si un utilisateur existe") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Résultat de la vérification"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "sync_manager"}) - public Response checkUserExists( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { - log.info("GET /api/sync/check/user/{} - realm: {}", userId, realmName); + @Override + @RolesAllowed({ "admin", "sync_manager", "user_viewer" }) + public SyncHistoryDTO getLastSyncStatus(String realmName) { + log.info("REST: getLastSyncStatus pour realm: {}", realmName); + return SyncHistoryDTO.builder() + .realmName(realmName) + .status("NEVER_SYNCED") + .build(); + } + @Override + @RolesAllowed({ "admin", "sync_manager" }) + public SyncHistoryDTO forceSyncRealm(String realmName) { + log.info("REST: forceSyncRealm pour realm: {}", realmName); try { - // Vérifier l'existence de l'utilisateur n'est plus disponible directement - // On retourne false car cette fonctionnalité n'est plus dans l'interface - boolean exists = false; - return Response.ok(new ExistsCheckResponse(exists, "user", userId)).build(); + syncService.forceSyncRealm(realmName); + return SyncHistoryDTO.builder() + .realmName(realmName) + .status("SUCCESS") + .build(); } catch (Exception e) { - log.error("Erreur lors de la vérification de l'utilisateur {} dans le realm {}", - userId, realmName, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - // ==================== DTOs internes ==================== - - @Schema(description = "Réponse de synchronisation d'utilisateurs") - public static class SyncUsersResponse { - @Schema(description = "Nombre d'utilisateurs synchronisés") - public int count; - - @Schema(description = "Liste des utilisateurs synchronisés") - public List users; - - public SyncUsersResponse(int count, List users) { - this.count = count; - this.users = users; - } - } - - @Schema(description = "Réponse de synchronisation de rôles") - public static class SyncRolesResponse { - @Schema(description = "Nombre de rôles synchronisés") - public int count; - - @Schema(description = "Liste des rôles synchronisés") - public List roles; - - public SyncRolesResponse(int count, List roles) { - this.count = count; - this.roles = roles; - } - } - - @Schema(description = "Réponse de vérification de santé") - public static class HealthCheckResponse { - @Schema(description = "Indique si Keycloak est accessible") - public boolean healthy; - - @Schema(description = "Message descriptif") - public String message; - - public HealthCheckResponse(boolean healthy, String message) { - this.healthy = healthy; - this.message = message; - } - } - - @Schema(description = "Réponse de vérification d'existence") - public static class ExistsCheckResponse { - @Schema(description = "Indique si la ressource existe") - public boolean exists; - - @Schema(description = "Type de ressource (realm, user, client, etc.)") - public String resourceType; - - @Schema(description = "Identifiant de la ressource") - public String resourceId; - - public ExistsCheckResponse(boolean exists, String resourceType, String resourceId) { - this.exists = exists; - this.resourceType = resourceType; - this.resourceId = resourceId; - } - } - - @Schema(description = "Réponse d'erreur") - public static class ErrorResponse { - @Schema(description = "Message d'erreur") - public String message; - - public ErrorResponse(String message) { - this.message = message; + log.error("Erreur forceSyncRealm realm {}", realmName, e); + return SyncHistoryDTO.builder() + .realmName(realmName) + .status("FAILED") + .build(); } } } diff --git a/src/main/java/dev/lions/user/manager/resource/UserMetricsResource.java b/src/main/java/dev/lions/user/manager/resource/UserMetricsResource.java new file mode 100644 index 0000000..a82d6c4 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/resource/UserMetricsResource.java @@ -0,0 +1,73 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.api.UserMetricsResourceApi; +import dev.lions.user.manager.client.KeycloakAdminClient; +import dev.lions.user.manager.dto.common.UserSessionStatsDTO; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.UserRepresentation; + +import java.util.List; + +/** + * Ressource REST fournissant des métriques agrégées sur les utilisateurs. + * Implémente l'interface API commune. + * + * Toutes les valeurs sont calculées en temps réel à partir de Keycloak + * (aucune approximation ni cache local). + */ +@Slf4j +@ApplicationScoped +@Path("/api/metrics/users") +public class UserMetricsResource implements UserMetricsResourceApi { + + @Inject + KeycloakAdminClient keycloakAdminClient; + + @Override + @RolesAllowed({ "admin", "user_manager", "auditor" }) + public UserSessionStatsDTO getUserSessionStats(String realmName) { + String effectiveRealm = (realmName == null || realmName.isBlank()) ? "master" : realmName; + log.info("GET /api/metrics/users/sessions - realm={}", effectiveRealm); + + try { + RealmResource realm = keycloakAdminClient.getRealm(effectiveRealm); + UsersResource usersResource = realm.users(); + + // Liste complète des utilisateurs du realm (source de vérité Keycloak) + List users = usersResource.list(); + long totalUsers = users.size(); + + long activeSessions = 0L; + long onlineUsers = 0L; + + for (UserRepresentation user : users) { + UserResource userResource = usersResource.get(user.getId()); + int sessionsForUser = userResource.getUserSessions().size(); + + activeSessions += sessionsForUser; + if (sessionsForUser > 0) { + onlineUsers++; + } + } + + return UserSessionStatsDTO.builder() + .realmName(effectiveRealm) + .totalUsers(totalUsers) + .activeSessions(activeSessions) + .onlineUsers(onlineUsers) + .build(); + } catch (Exception e) { + log.error("Erreur lors du calcul des statistiques de sessions pour le realm {}", effectiveRealm, e); + // On laisse l'exception remonter pour signaler une vraie erreur (pas de valeur approximative) + throw new RuntimeException("Impossible de calculer les statistiques de sessions en temps réel", e); + } + } +} + diff --git a/src/main/java/dev/lions/user/manager/resource/UserResource.java b/src/main/java/dev/lions/user/manager/resource/UserResource.java index 5f82741..c50d431 100644 --- a/src/main/java/dev/lions/user/manager/resource/UserResource.java +++ b/src/main/java/dev/lions/user/manager/resource/UserResource.java @@ -1,139 +1,62 @@ package dev.lions.user.manager.resource; -import dev.lions.user.manager.dto.user.UserDTO; -import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; -import dev.lions.user.manager.dto.user.UserSearchResultDTO; +import dev.lions.user.manager.api.UserResourceApi; +import dev.lions.user.manager.dto.common.ApiErrorDTO; +import dev.lions.user.manager.dto.importexport.ImportResultDTO; +import dev.lions.user.manager.dto.user.*; import dev.lions.user.manager.service.UserService; -import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.*; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; -import java.time.LocalDateTime; import java.util.List; /** * REST Resource pour la gestion des utilisateurs - * Endpoints exposés pour les opérations CRUD sur les utilisateurs Keycloak + * Implémente l'interface API commune. */ -@Path("/api/users") -@Produces(MediaType.APPLICATION_JSON) -@Consumes(MediaType.APPLICATION_JSON) -@Tag(name = "Users", description = "Gestion des utilisateurs Keycloak") -@PermitAll // DEV: Permet l'accès sans authentification (écrasé par @RolesAllowed sur les méthodes en PROD) @Slf4j -public class UserResource { +@jakarta.enterprise.context.ApplicationScoped +@jakarta.ws.rs.Path("/api/users") +public class UserResource implements UserResourceApi { @Inject UserService userService; - @POST - @Path("/search") - @Operation(summary = "Rechercher des utilisateurs", description = "Recherche d'utilisateurs selon des critères") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Résultats de recherche", - content = @Content(schema = @Schema(implementation = UserSearchResultDTO.class))), - @APIResponse(responseCode = "400", description = "Critères invalides"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response searchUsers(@Valid @NotNull UserSearchCriteriaDTO criteria) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public UserSearchResultDTO searchUsers(@Valid @NotNull UserSearchCriteriaDTO criteria) { log.info("POST /api/users/search - Recherche d'utilisateurs"); - - try { - UserSearchResultDTO result = userService.searchUsers(criteria); - return Response.ok(result).build(); - } catch (Exception e) { - log.error("Erreur lors de la recherche d'utilisateurs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return userService.searchUsers(criteria); } - @GET - @Path("/{userId}") - @Operation(summary = "Récupérer un utilisateur par ID", description = "Récupère les détails d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Utilisateur trouvé", - content = @Content(schema = @Schema(implementation = UserDTO.class))), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager", "user_viewer"}) - public Response getUserById( - @Parameter(description = "ID de l'utilisateur") @PathParam("userId") @NotBlank String userId, - @Parameter(description = "Nom du realm") @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager", "user_viewer" }) + public UserDTO getUserById(String userId, String realmName) { log.info("GET /api/users/{} - realm: {}", userId, realmName); - - try { - return userService.getUserById(userId, realmName) - .map(user -> Response.ok(user).build()) - .orElse(Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse("Utilisateur non trouvé")) - .build()); - } catch (Exception e) { - log.error("Erreur lors de la récupération de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return userService.getUserById(userId, realmName) + .orElseThrow(() -> new RuntimeException("Utilisateur non trouvé")); // ExceptionMapper should handle/map + // to 404 } - @GET - @Operation(summary = "Lister tous les utilisateurs", description = "Liste paginée de tous les utilisateurs") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des utilisateurs", - content = @Content(schema = @Schema(implementation = UserSearchResultDTO.class))), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager", "user_viewer"}) - public Response getAllUsers( - @QueryParam("realm") @NotBlank String realmName, - @QueryParam("page") @DefaultValue("0") int page, - @QueryParam("pageSize") @DefaultValue("20") int pageSize - ) { + @Override + @RolesAllowed({ "admin", "user_manager", "user_viewer" }) + public UserSearchResultDTO getAllUsers(String realmName, int page, int pageSize) { log.info("GET /api/users - realm: {}, page: {}, pageSize: {}", realmName, page, pageSize); - - try { - UserSearchResultDTO result = userService.getAllUsers(realmName, page, pageSize); - return Response.ok(result).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des utilisateurs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return userService.getAllUsers(realmName, page, pageSize); } - @POST - @Operation(summary = "Créer un utilisateur", description = "Crée un nouvel utilisateur dans Keycloak") - @APIResponses({ - @APIResponse(responseCode = "201", description = "Utilisateur créé", - content = @Content(schema = @Schema(implementation = UserDTO.class))), - @APIResponse(responseCode = "400", description = "Données invalides"), - @APIResponse(responseCode = "409", description = "Utilisateur existe déjà"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response createUser( - @Valid @NotNull UserDTO user, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public Response createUser(@Valid @NotNull UserDTO user, String realmName) { log.info("POST /api/users - Création d'un utilisateur: {}", user.getUsername()); try { @@ -142,380 +65,97 @@ public class UserResource { } catch (IllegalArgumentException e) { log.warn("Données invalides lors de la création: {}", e.getMessage()); return Response.status(Response.Status.CONFLICT) - .entity(new ErrorResponse(e.getMessage())) - .build(); + .entity(new ApiErrorDTO(e.getMessage())) + .build(); } catch (Exception e) { log.error("Erreur lors de la création de l'utilisateur", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); + throw new RuntimeException(e); } } - @PUT - @Path("/{userId}") - @Operation(summary = "Mettre à jour un utilisateur", description = "Met à jour les informations d'un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Utilisateur mis à jour", - content = @Content(schema = @Schema(implementation = UserDTO.class))), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "400", description = "Données invalides"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response updateUser( - @PathParam("userId") @NotBlank String userId, - @NotNull UserDTO user, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public UserDTO updateUser(String userId, @Valid @NotNull UserDTO user, String realmName) { log.info("PUT /api/users/{} - Mise à jour", userId); - - try { - // Validation manuelle des champs obligatoires - if (user.getPrenom() == null || user.getPrenom().trim().isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Le prénom est obligatoire")) - .build(); - } - if (user.getPrenom().length() < 2 || user.getPrenom().length() > 100) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Le prénom doit contenir entre 2 et 100 caractères")) - .build(); - } - if (user.getNom() == null || user.getNom().trim().isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Le nom est obligatoire")) - .build(); - } - if (user.getNom().length() < 2 || user.getNom().length() > 100) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Le nom doit contenir entre 2 et 100 caractères")) - .build(); - } - if (user.getEmail() == null || user.getEmail().trim().isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("L'email est obligatoire")) - .build(); - } - if (!user.getEmail().matches("^[A-Za-z0-9+_.-]+@(.+)$")) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Format d'email invalide")) - .build(); - } - - UserDTO updatedUser = userService.updateUser(userId, user, realmName); - return Response.ok(updatedUser).build(); - } catch (RuntimeException e) { - if (e.getMessage().contains("non trouvé")) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - log.error("Erreur lors de la mise à jour de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return userService.updateUser(userId, user, realmName); } - @DELETE - @Path("/{userId}") - @Operation(summary = "Supprimer un utilisateur", description = "Supprime un utilisateur (soft ou hard delete)") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Utilisateur supprimé"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin"}) - public Response deleteUser( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @QueryParam("hardDelete") @DefaultValue("false") boolean hardDelete - ) { + @Override + @RolesAllowed({ "admin" }) + public void deleteUser(String userId, String realmName, boolean hardDelete) { log.info("DELETE /api/users/{} - realm: {}, hardDelete: {}", userId, realmName, hardDelete); - - try { - userService.deleteUser(userId, realmName, hardDelete); - return Response.noContent().build(); - } catch (RuntimeException e) { - if (e.getMessage().contains("non trouvé")) { - return Response.status(Response.Status.NOT_FOUND) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - log.error("Erreur lors de la suppression de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + userService.deleteUser(userId, realmName, hardDelete); } - @POST - @Path("/{userId}/activate") - @Operation(summary = "Activer un utilisateur", description = "Active un utilisateur désactivé") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Utilisateur activé"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response activateUser( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public void activateUser(String userId, String realmName) { log.info("POST /api/users/{}/activate", userId); - - try { - userService.activateUser(userId, realmName); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de l'activation de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + userService.activateUser(userId, realmName); } - @POST - @Path("/{userId}/deactivate") - @Operation(summary = "Désactiver un utilisateur", description = "Désactive un utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Utilisateur désactivé"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response deactivateUser( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @QueryParam("raison") String raison - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public void deactivateUser(String userId, String realmName, String raison) { log.info("POST /api/users/{}/deactivate - raison: {}", userId, raison); - - try { - userService.deactivateUser(userId, realmName, raison); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la désactivation de l'utilisateur {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + userService.deactivateUser(userId, realmName, raison); } - @POST - @Path("/{userId}/reset-password") - @Operation(summary = "Réinitialiser le mot de passe", description = "Définit un nouveau mot de passe pour l'utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Mot de passe réinitialisé"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response resetPassword( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName, - @NotNull PasswordResetRequest request - ) { - log.info("POST /api/users/{}/reset-password - temporary: {}", userId, request.temporary); - - try { - userService.resetPassword(userId, realmName, request.password, request.temporary); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de la réinitialisation du mot de passe pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + @Override + @RolesAllowed({ "admin", "user_manager" }) + public void resetPassword(String userId, String realmName, @NotNull PasswordResetRequestDTO request) { + log.info("POST /api/users/{}/reset-password - temporary: {}", userId, request.isTemporary()); + userService.resetPassword(userId, realmName, request.getPassword(), request.isTemporary()); } - @POST - @Path("/{userId}/send-verification-email") - @Operation(summary = "Envoyer email de vérification", description = "Envoie un email de vérification à l'utilisateur") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Email envoyé"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response sendVerificationEmail( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public void sendVerificationEmail(String userId, String realmName) { log.info("POST /api/users/{}/send-verification-email", userId); - - try { - userService.sendVerificationEmail(userId, realmName); - return Response.noContent().build(); - } catch (Exception e) { - log.error("Erreur lors de l'envoi de l'email de vérification pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + userService.sendVerificationEmail(userId, realmName); } - @POST - @Path("/{userId}/logout-sessions") - @Operation(summary = "Déconnecter toutes les sessions", description = "Révoque toutes les sessions actives de l'utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Sessions révoquées"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - public Response logoutAllSessions( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager" }) + public SessionsRevokedDTO logoutAllSessions(String userId, String realmName) { log.info("POST /api/users/{}/logout-sessions", userId); - - try { - int count = userService.logoutAllSessions(userId, realmName); - return Response.ok(new SessionsRevokedResponse(count)).build(); - } catch (Exception e) { - log.error("Erreur lors de la déconnexion des sessions pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + int count = userService.logoutAllSessions(userId, realmName); + return new SessionsRevokedDTO(count); } - @GET - @Path("/{userId}/sessions") - @Operation(summary = "Récupérer les sessions actives", description = "Liste les sessions actives de l'utilisateur") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Liste des sessions"), - @APIResponse(responseCode = "404", description = "Utilisateur non trouvé"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager", "user_viewer"}) - public Response getActiveSessions( - @PathParam("userId") @NotBlank String userId, - @QueryParam("realm") @NotBlank String realmName - ) { + @Override + @RolesAllowed({ "admin", "user_manager", "user_viewer" }) + public List getActiveSessions(String userId, String realmName) { log.info("GET /api/users/{}/sessions", userId); - - try { - List sessions = userService.getActiveSessions(userId, realmName); - return Response.ok(sessions).build(); - } catch (Exception e) { - log.error("Erreur lors de la récupération des sessions pour {}", userId, e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } + return userService.getActiveSessions(userId, realmName); } - /** - * Exporter les utilisateurs en CSV - */ + @Override @GET - @Path("/export/csv") - @Operation(summary = "Exporter les utilisateurs en CSV") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Fichier CSV généré avec succès"), - @APIResponse(responseCode = "400", description = "Realm manquant ou invalide"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager", "user_viewer"}) - @Produces(MediaType.TEXT_PLAIN) - public Response exportUsersToCSV(@QueryParam("realm") @NotBlank String realmName) { + @jakarta.ws.rs.Path("/export/csv") + @jakarta.ws.rs.Produces("text/csv") + @RolesAllowed({ "admin", "user_manager" }) + public Response exportUsersToCSV(@QueryParam("realm") String realmName) { log.info("GET /api/users/export/csv - realm: {}", realmName); - - try { - UserSearchCriteriaDTO criteria = UserSearchCriteriaDTO.builder() + UserSearchCriteriaDTO criteria = UserSearchCriteriaDTO.builder() .realmName(realmName) - .pageSize(10000) // Export complet sans pagination .page(0) + .pageSize(10_000) .build(); - - String csvContent = userService.exportUsersToCSV(criteria); - - String filename = "users_export_" + - LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss")) + - ".csv"; - - return Response.ok(csvContent) - .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + String csv = userService.exportUsersToCSV(criteria); + return Response.ok(csv) + .type(MediaType.valueOf("text/csv")) + .header("Content-Disposition", "attachment; filename=\"users-" + (realmName != null ? realmName : "export") + ".csv\"") .build(); - } catch (Exception e) { - log.error("Erreur lors de l'export CSV des utilisateurs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } } - /** - * Importer des utilisateurs depuis CSV avec rapport détaillé - */ + @Override @POST - @Path("/import/csv") - @Operation(summary = "Importer des utilisateurs depuis un fichier CSV") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Import terminé avec rapport détaillé"), - @APIResponse(responseCode = "400", description = "Fichier CSV vide ou invalide"), - @APIResponse(responseCode = "500", description = "Erreur serveur") - }) - @RolesAllowed({"admin", "user_manager"}) - @Consumes(MediaType.TEXT_PLAIN) - @Produces(MediaType.APPLICATION_JSON) - public Response importUsersFromCSV( - @QueryParam("realm") @NotBlank String realmName, - String csvContent) { + @jakarta.ws.rs.Path("/import/csv") + @jakarta.ws.rs.Consumes(MediaType.TEXT_PLAIN) + @RolesAllowed({ "admin", "user_manager" }) + public ImportResultDTO importUsersFromCSV(@QueryParam("realm") String realmName, String csvContent) { log.info("POST /api/users/import/csv - realm: {}", realmName); - - try { - if (csvContent == null || csvContent.trim().isEmpty()) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(new ErrorResponse("Le contenu CSV est vide")) - .build(); - } - - dev.lions.user.manager.dto.importexport.ImportResultDTO result = userService.importUsersFromCSV(csvContent, realmName); - - log.info("{} utilisateur(s) importé(s) dans le realm {} ({} erreur(s))", - result.getSuccessCount(), realmName, result.getErrorCount()); - - return Response.ok(result).build(); - } catch (Exception e) { - log.error("Erreur lors de l'import CSV des utilisateurs", e); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(new ErrorResponse(e.getMessage())) - .build(); - } - } - - // ==================== DTOs internes ==================== - - @Schema(description = "Requête de réinitialisation de mot de passe") - public static class PasswordResetRequest { - @Schema(description = "Nouveau mot de passe", required = true) - public String password; - - @Schema(description = "Indique si le mot de passe est temporaire", defaultValue = "true") - public boolean temporary = true; - } - - @Schema(description = "Réponse de révocation de sessions") - public static class SessionsRevokedResponse { - @Schema(description = "Nombre de sessions révoquées") - public int count; - - public SessionsRevokedResponse(int count) { - this.count = count; - } - } - - @Schema(description = "Réponse d'erreur") - public static class ErrorResponse { - @Schema(description = "Message d'erreur") - public String message; - - public ErrorResponse(String message) { - this.message = message; - } + return userService.importUsersFromCSV(csvContent, realmName); } } diff --git a/src/main/java/dev/lions/user/manager/security/DevModeSecurityAugmentor.java b/src/main/java/dev/lions/user/manager/security/DevModeSecurityAugmentor.java index 5c51ab7..c19bde4 100644 --- a/src/main/java/dev/lions/user/manager/security/DevModeSecurityAugmentor.java +++ b/src/main/java/dev/lions/user/manager/security/DevModeSecurityAugmentor.java @@ -4,6 +4,7 @@ import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentityAugmentor; import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.arc.profile.IfBuildProfile; import io.smallrye.mutiny.Uni; import jakarta.enterprise.context.ApplicationScoped; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -16,6 +17,7 @@ import java.util.Set; * Permet de tester l'API sans authentification Keycloak */ @ApplicationScoped +@IfBuildProfile("dev") public class DevModeSecurityAugmentor implements SecurityIdentityAugmentor { @ConfigProperty(name = "quarkus.oidc.enabled", defaultValue = "true") diff --git a/src/main/java/dev/lions/user/manager/server/impl/entity/SyncHistoryEntity.java b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncHistoryEntity.java new file mode 100644 index 0000000..c11764f --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncHistoryEntity.java @@ -0,0 +1,50 @@ +package dev.lions.user.manager.server.impl.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Index; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * Entité représentant l'historique des synchronisations avec Keycloak. + */ +@Entity +@Table(name = "sync_history", indexes = { + @Index(name = "idx_sync_realm", columnList = "realm_name"), + @Index(name = "idx_sync_date", columnList = "sync_date") +}) +@Data +@EqualsAndHashCode(callSuper = true) +public class SyncHistoryEntity extends PanacheEntity { + + @Column(name = "realm_name", nullable = false) + private String realmName; + + @Column(name = "sync_date", nullable = false) + private LocalDateTime syncDate; + + // USER ou ROLE + @Column(name = "sync_type", nullable = false) + private String syncType; + + @Column(name = "status", nullable = false) // SUCCESS, FAILURE + private String status; + + @Column(name = "items_processed") + private Integer itemsProcessed; + + @Column(name = "duration_ms") + private Long durationMs; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + public SyncHistoryEntity() { + this.syncDate = LocalDateTime.now(); + } +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedRoleEntity.java b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedRoleEntity.java new file mode 100644 index 0000000..311631d --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedRoleEntity.java @@ -0,0 +1,32 @@ +package dev.lions.user.manager.server.impl.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Snapshot local d'un rôle Keycloak synchronisé. + */ +@Entity +@Table(name = "synced_role", indexes = { + @Index(name = "idx_synced_role_realm", columnList = "realm_name"), + @Index(name = "idx_synced_role_realm_name", columnList = "realm_name,role_name", unique = true) +}) +@Data +@EqualsAndHashCode(callSuper = true) +public class SyncedRoleEntity extends PanacheEntity { + + @Column(name = "realm_name", nullable = false) + private String realmName; + + @Column(name = "role_name", nullable = false) + private String roleName; + + @Column(name = "description") + private String description; +} + diff --git a/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedUserEntity.java b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedUserEntity.java new file mode 100644 index 0000000..843a914 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/entity/SyncedUserEntity.java @@ -0,0 +1,47 @@ +package dev.lions.user.manager.server.impl.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * Snapshot local d'un utilisateur Keycloak synchronisé. + * Permet de conserver un état minimal pour des rapports ou vérifications de cohérence. + */ +@Entity +@Table(name = "synced_user", indexes = { + @Index(name = "idx_synced_user_realm", columnList = "realm_name"), + @Index(name = "idx_synced_user_realm_kc_id", columnList = "realm_name,keycloak_id", unique = true) +}) +@Data +@EqualsAndHashCode(callSuper = true) +public class SyncedUserEntity extends PanacheEntity { + + @Column(name = "realm_name", nullable = false) + private String realmName; + + @Column(name = "keycloak_id", nullable = false) + private String keycloakId; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "email") + private String email; + + @Column(name = "enabled") + private Boolean enabled; + + @Column(name = "email_verified") + private Boolean emailVerified; + + @Column(name = "created_at") + private LocalDateTime createdAt; +} + diff --git a/src/main/java/dev/lions/user/manager/server/impl/interceptor/AuditInterceptor.java b/src/main/java/dev/lions/user/manager/server/impl/interceptor/AuditInterceptor.java new file mode 100644 index 0000000..e968735 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/interceptor/AuditInterceptor.java @@ -0,0 +1,93 @@ +package dev.lions.user.manager.server.impl.interceptor; + +import dev.lions.user.manager.dto.audit.AuditLogDTO; +import dev.lions.user.manager.enums.audit.TypeActionAudit; +import dev.lions.user.manager.service.AuditService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Logged +@Interceptor +@Priority(Interceptor.Priority.APPLICATION) +@Slf4j +public class AuditInterceptor { + + @Inject + AuditService auditService; + + @Inject + SecurityIdentity securityIdentity; + + @AroundInvoke + public Object auditMethod(InvocationContext context) throws Exception { + Logged annotation = context.getMethod().getAnnotation(Logged.class); + if (annotation == null) { + annotation = context.getTarget().getClass().getAnnotation(Logged.class); + } + + String actionStr = annotation != null ? annotation.action() : "UNKNOWN"; + String resourceType = annotation != null ? annotation.resource() : "UNKNOWN"; + String username = securityIdentity.isAnonymous() ? "anonymous" : securityIdentity.getPrincipal().getName(); + + // Extraction du realm depuis l'issuer JWT (ex: http://keycloak/realms/lions-user-manager) + String realmName = "unknown"; + if (!securityIdentity.isAnonymous() + && securityIdentity.getPrincipal() instanceof org.eclipse.microprofile.jwt.JsonWebToken jwt) { + String issuer = jwt.getIssuer(); + if (issuer != null && issuer.contains("/realms/")) { + realmName = issuer.substring(issuer.lastIndexOf("/realms/") + 8); + } + } + + // Tentative d'extraction de l'ID de la ressource (1er argument String) + String resourceId = ""; + if (context.getParameters().length > 0 && context.getParameters()[0] instanceof String) { + resourceId = (String) context.getParameters()[0]; + } + + try { + Object result = context.proceed(); + + // Log Success + try { + TypeActionAudit action = TypeActionAudit.valueOf(actionStr); + auditService.logSuccess( + action, + resourceType, + resourceId, + null, + realmName, + username, + "Action réussie via AOP"); + } catch (IllegalArgumentException e) { + log.warn("Type d'action audit inconnu: {}", actionStr); + } + + return result; + } catch (Exception e) { + // Log Failure + try { + TypeActionAudit action = TypeActionAudit.valueOf(actionStr); + auditService.logFailure( + action, + resourceType, + resourceId, + null, + realmName, + username, + "ERROR", + e.getMessage()); + } catch (IllegalArgumentException ex) { + log.warn("Type d'action audit inconnu: {}", actionStr); + } + throw e; + } + } +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/interceptor/Logged.java b/src/main/java/dev/lions/user/manager/server/impl/interceptor/Logged.java new file mode 100644 index 0000000..728ed43 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/interceptor/Logged.java @@ -0,0 +1,26 @@ +package dev.lions.user.manager.server.impl.interceptor; + +import jakarta.interceptor.InterceptorBinding; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation pour auditer automatiquement l'exécution d'une méthode. + */ +@InterceptorBinding +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface Logged { + + /** + * Type d'action d'audit (ex: UPDATE_USER). + */ + String action() default ""; + + /** + * Type de ressource concernée (ex: USER). + */ + String resource() default ""; +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/mapper/SyncHistoryMapper.java b/src/main/java/dev/lions/user/manager/server/impl/mapper/SyncHistoryMapper.java new file mode 100644 index 0000000..48beff6 --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/mapper/SyncHistoryMapper.java @@ -0,0 +1,21 @@ +package dev.lions.user.manager.server.impl.mapper; + +import dev.lions.user.manager.dto.sync.SyncHistoryDTO; +import dev.lions.user.manager.server.impl.entity.SyncHistoryEntity; +import org.mapstruct.*; + +import java.util.List; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA_CDI, injectionStrategy = InjectionStrategy.CONSTRUCTOR, unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface SyncHistoryMapper { + + @Mapping(target = "id", source = "id", qualifiedByName = "longToString") + SyncHistoryDTO toDTO(SyncHistoryEntity entity); + + List toDTOList(List entities); + + @Named("longToString") + default String longToString(Long id) { + return id != null ? id.toString() : null; + } +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/repository/AuditLogRepository.java b/src/main/java/dev/lions/user/manager/server/impl/repository/AuditLogRepository.java new file mode 100644 index 0000000..95d56ea --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/repository/AuditLogRepository.java @@ -0,0 +1,62 @@ +package dev.lions.user.manager.server.impl.repository; + +import dev.lions.user.manager.enums.audit.TypeActionAudit; +import dev.lions.user.manager.server.impl.entity.AuditLogEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class AuditLogRepository implements PanacheRepository { + + public List search(String realmName, + String auteurAction, + LocalDateTime dateDebut, + LocalDateTime dateFin, + String typeAction, + Boolean success, + int page, + int pageSize) { + + StringBuilder query = new StringBuilder("1=1"); + Map params = new HashMap<>(); + + // Construction dynamique de la requête + if (realmName != null && !realmName.isEmpty()) { + query.append(" AND realmName = :realmName"); + params.put("realmName", realmName); + } + if (auteurAction != null && !auteurAction.isEmpty()) { + query.append(" AND auteurAction = :auteurAction"); + params.put("auteurAction", auteurAction); + } + if (dateDebut != null) { + query.append(" AND timestamp >= :dateDebut"); + params.put("dateDebut", dateDebut); + } + if (dateFin != null) { + query.append(" AND timestamp <= :dateFin"); + params.put("dateFin", dateFin); + } + if (typeAction != null && !typeAction.isEmpty()) { + try { + TypeActionAudit actionEnum = TypeActionAudit.valueOf(typeAction); + query.append(" AND action = :actionEnum"); + params.put("actionEnum", actionEnum); + } catch (IllegalArgumentException e) { + // Ignore invalid enum value filter + } + } + if (success != null) { + query.append(" AND success = :success"); + params.put("success", success); + } + + query.append(" ORDER BY timestamp DESC"); + return find(query.toString(), params).page(page, pageSize).list(); + } +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/repository/SyncHistoryRepository.java b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncHistoryRepository.java new file mode 100644 index 0000000..7aa00bc --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncHistoryRepository.java @@ -0,0 +1,17 @@ +package dev.lions.user.manager.server.impl.repository; + +import dev.lions.user.manager.server.impl.entity.SyncHistoryEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +@ApplicationScoped +public class SyncHistoryRepository implements PanacheRepository { + + public List findLatestByRealm(String realmName, int limit) { + return find("realmName = ?1 ORDER BY syncDate DESC", realmName) + .page(0, limit) + .list(); + } +} diff --git a/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedRoleRepository.java b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedRoleRepository.java new file mode 100644 index 0000000..228ab4e --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedRoleRepository.java @@ -0,0 +1,20 @@ +package dev.lions.user.manager.server.impl.repository; + +import dev.lions.user.manager.server.impl.entity.SyncedRoleEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +@ApplicationScoped +public class SyncedRoleRepository implements PanacheRepository { + + /** + * Remplace l'ensemble des snapshots de rôles pour un realm donné. + */ + public void replaceForRealm(String realmName, List roles) { + delete("realmName", realmName); + persist(roles); + } +} + diff --git a/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedUserRepository.java b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedUserRepository.java new file mode 100644 index 0000000..806bb2f --- /dev/null +++ b/src/main/java/dev/lions/user/manager/server/impl/repository/SyncedUserRepository.java @@ -0,0 +1,20 @@ +package dev.lions.user.manager.server.impl.repository; + +import dev.lions.user.manager.server.impl.entity.SyncedUserEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +@ApplicationScoped +public class SyncedUserRepository implements PanacheRepository { + + /** + * Remplace l'ensemble des snapshots d'utilisateurs pour un realm donné. + */ + public void replaceForRealm(String realmName, List users) { + delete("realmName", realmName); + persist(users); + } +} + diff --git a/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java index 2924283..a856519 100644 --- a/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java +++ b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java @@ -2,11 +2,15 @@ package dev.lions.user.manager.service.impl; import dev.lions.user.manager.dto.audit.AuditLogDTO; import dev.lions.user.manager.enums.audit.TypeActionAudit; +// import dev.lions.user.manager.mapper.AuditLogMapper; // DELETE - Wrong package +import dev.lions.user.manager.server.impl.mapper.AuditLogMapper; // ADD - Correct package import dev.lions.user.manager.server.impl.entity.AuditLogEntity; -import dev.lions.user.manager.server.impl.mapper.AuditLogMapper; +import dev.lions.user.manager.server.impl.repository.AuditLogRepository; import dev.lions.user.manager.service.AuditService; +import io.quarkus.hibernate.orm.panache.PanacheQuery; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; import jakarta.transaction.Transactional; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -15,596 +19,344 @@ import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -/** - * Implémentation du service d'audit avec support de la persistance PostgreSQL. - * - *

Architecture Hybride:

- *
    - *
  • Cache en mémoire - Pour les logs récents (performances)
  • - *
  • Persistance PostgreSQL - Pour l'historique long terme (activable via config)
  • - *
- * - *

Configuration:

- *
    - *
  • {@code lions.audit.enabled} - Active/désactive l'audit (défaut: true)
  • - *
  • {@code lions.audit.log-to-database} - Active la persistance DB (défaut: false en dev, true en prod)
  • - *
  • {@code lions.audit.cache-size} - Taille max du cache mémoire (défaut: 10000)
  • - *
  • {@code lions.audit.retention-days} - Durée de rétention en jours (défaut: 365)
  • - *
- * - *

Modes de Fonctionnement:

- *
- * Mode DEV (logToDatabase=false):
- *   - Stockage en mémoire uniquement
- *   - Logs perdus au redémarrage
- *   - Performances maximales
- *
- * Mode PROD (logToDatabase=true):
- *   - Persistance PostgreSQL
- *   - Cache mémoire pour requêtes fréquentes
- *   - Historique complet préservé
- * 
- * - * @author Lions Development Team - * @version 2.0.0 - * @since 2026-01-02 - */ @ApplicationScoped @Slf4j public class AuditServiceImpl implements AuditService { - // ==================== DÉPENDANCES ==================== + @Inject + AuditLogRepository auditLogRepository; @Inject AuditLogMapper auditLogMapper; - // ==================== CONFIGURATION ==================== + @Inject + EntityManager entityManager; @ConfigProperty(name = "lions.audit.enabled", defaultValue = "true") boolean auditEnabled; - @ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "false") + @ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "true") boolean logToDatabase; - @ConfigProperty(name = "lions.audit.cache-size", defaultValue = "10000") - int cacheSize; - - @ConfigProperty(name = "lions.audit.retention-days", defaultValue = "365") - int retentionDays; - - // ==================== STOCKAGE ==================== - - /** - * Cache en mémoire pour les logs récents. - *

Limité à {@code cacheSize} entrées. Les plus anciens sont supprimés automatiquement.

- */ - private final Map auditLogsCache = new ConcurrentHashMap<>(); - - // ==================== MÉTHODES PRINCIPALES ==================== - @Override - @Transactional + @Transactional(Transactional.TxType.REQUIRES_NEW) public AuditLogDTO logAction(@Valid @NotNull AuditLogDTO auditLog) { if (!auditEnabled) { - log.debug("Audit désactivé, log ignoré"); + log.debug("Audit désactivé, action ignorée: {}", auditLog.getTypeAction()); return auditLog; } - // Générer un ID si nécessaire - if (auditLog.getId() == null) { - auditLog.setId(UUID.randomUUID().toString()); - } + log.info("AUDIT: [{}] {} - user:{} - ressource:{}/{} - status:{}", + auditLog.getRealmName(), + auditLog.getTypeAction(), + auditLog.getActeurUsername(), // ou getActeurUserId() + auditLog.getRessourceType(), + auditLog.getRessourceId(), + auditLog.getSuccess() != null && auditLog.getSuccess() ? "SUCCESS" : "FAILURE"); - // Ajouter le timestamp si nécessaire - if (auditLog.getDateAction() == null) { - auditLog.setDateAction(LocalDateTime.now()); - } - - // Log structuré pour les systèmes de logging externes (Graylog, Elasticsearch, etc.) - log.info("AUDIT | Type: {} | Acteur: {} | Ressource: {} | Succès: {} | IP: {} | Détails: {}", - auditLog.getTypeAction(), - auditLog.getActeurUsername(), - auditLog.getRessourceType() + ":" + auditLog.getRessourceId(), - auditLog.isSuccessful(), - auditLog.getIpAddress(), - auditLog.getDescription()); - - // Stocker en base de données si activé if (logToDatabase) { try { + // Ensure dateAction is set + if (auditLog.getDateAction() == null) { + auditLog.setDateAction(LocalDateTime.now()); + } + AuditLogEntity entity = auditLogMapper.toEntity(auditLog); - // Le mapper s'occupe du mapping automatique via @Mapping annotations - // Ajout des champs additionnels non mappés automatiquement - entity.setRealmName(auditLog.getRealmName()); + auditLogRepository.persist(entity); - entity.persist(); - - log.debug("Log d'audit persisté en base de données avec ID: {}", entity.id); + // Mettre à jour l'ID du DTO avec l'ID généré par la base + if (entity.id != null) { + auditLog.setId(entity.id.toString()); + } } catch (Exception e) { - log.error("Erreur lors de la persistance du log d'audit en base de données", e); - // On ne lance pas d'exception pour ne pas bloquer le processus métier + log.error("Erreur lors de la persistance du log d'audit", e); + // On ne bloque pas l'action métier si l'audit échoue (sauf exigence contraire) } } - // Ajouter au cache mémoire (pour performances) - auditLogsCache.put(auditLog.getId(), auditLog); - - // Nettoyer le cache si trop grand - if (auditLogsCache.size() > cacheSize) { - cleanOldestCacheEntries(); - } - return auditLog; } @Override + @Transactional(Transactional.TxType.REQUIRES_NEW) public void logSuccess(@NotNull TypeActionAudit typeAction, - @NotBlank String ressourceType, - String ressourceId, - String ressourceName, - @NotBlank String realmName, - @NotBlank String acteurUserId, - String description) { - AuditLogDTO auditLog = AuditLogDTO.builder() - .acteurUserId(acteurUserId) - .acteurUsername(acteurUserId) - .typeAction(typeAction) - .ressourceType(ressourceType) - .ressourceId(ressourceId != null ? ressourceId : "") - .success(true) - .description(description) - .dateAction(LocalDateTime.now()) - .build(); + @NotBlank String ressourceType, + String ressourceId, + String ressourceName, + @NotBlank String realmName, + @NotBlank String acteurUserId, + String description) { - logAction(auditLog); + AuditLogDTO log = AuditLogDTO.builder() + .typeAction(typeAction) + .ressourceType(ressourceType) + .ressourceId(ressourceId) + .ressourceName(ressourceName) + .realmName(realmName) + .acteurUserId(acteurUserId) + .acteurUsername(acteurUserId) // On map aussi le username pour la persistence Entity + .description(description) + .dateAction(LocalDateTime.now()) + .success(true) + .build(); + + logAction(log); } @Override + @Transactional(Transactional.TxType.REQUIRES_NEW) public void logFailure(@NotNull TypeActionAudit typeAction, - @NotBlank String ressourceType, - String ressourceId, - String ressourceName, - @NotBlank String realmName, - @NotBlank String acteurUserId, - String errorCode, - String errorMessage) { - AuditLogDTO auditLog = AuditLogDTO.builder() - .acteurUserId(acteurUserId) - .acteurUsername(acteurUserId) - .typeAction(typeAction) - .ressourceType(ressourceType) - .ressourceId(ressourceId != null ? ressourceId : "") - .success(false) - .errorMessage(errorMessage) - .dateAction(LocalDateTime.now()) - .build(); + @NotBlank String ressourceType, + String ressourceId, + String ressourceName, + @NotBlank String realmName, + @NotBlank String acteurUserId, + String errorCode, + String errorMessage) { - logAction(auditLog); + AuditLogDTO log = AuditLogDTO.builder() + .typeAction(typeAction) + .ressourceType(ressourceType) + .ressourceId(ressourceId) + .ressourceName(ressourceName) + .realmName(realmName) + .acteurUserId(acteurUserId) + .acteurUsername(acteurUserId) + .description("Echec: " + errorCode) + .errorMessage(errorMessage) + .dateAction(LocalDateTime.now()) + .success(false) + .build(); + + logAction(log); } - // ==================== MÉTHODES DE RECHERCHE ==================== - @Override public List findByActeur(@NotBlank String acteurUserId, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - if (logToDatabase) { - return searchLogsFromDatabase(acteurUserId, dateDebut, dateFin, null, null, null, page, pageSize); - } - return searchLogsFromCache(acteurUserId, dateDebut, dateFin, null, null, null, page, pageSize); + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + // Le repository cherche par auteurAction, qui est mappé sur acteurUsername dans + // le DTO + List entities = auditLogRepository.search(null, acteurUserId, dateDebut, dateFin, null, null, + page, + pageSize); + return auditLogMapper.toDTOList(entities); } @Override public List findByRessource(@NotBlank String ressourceType, - @NotBlank String ressourceId, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - if (logToDatabase) { - return searchLogsFromDatabase(null, dateDebut, dateFin, null, ressourceType, null, page, pageSize) - .stream() - .filter(log -> ressourceId.equals(log.getRessourceId())) - .collect(Collectors.toList()); - } - return searchLogsFromCache(null, dateDebut, dateFin, null, ressourceType, null, page, pageSize) - .stream() - .filter(log -> ressourceId.equals(log.getRessourceId())) - .collect(Collectors.toList()); + @NotBlank String ressourceId, + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + + // Utilisation de Panache query directe car le repo search générique est limité + // On cherche dans 'details' (description) ou 'userId' (ressourceId) + String filter = "%" + ressourceId + "%"; + // Correction: userId est le nom du champ dans l'entité qui mappe ressourceId + PanacheQuery q = auditLogRepository.find("userId = ?1 or details like ?2", ressourceId, filter); + + return auditLogMapper.toDTOList(q.page(page, pageSize).list()); } @Override public List findByTypeAction(@NotNull TypeActionAudit typeAction, - @NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - if (logToDatabase) { - return searchLogsFromDatabase(null, dateDebut, dateFin, typeAction, null, null, page, pageSize); - } - return searchLogsFromCache(null, dateDebut, dateFin, typeAction, null, null, page, pageSize); + @NotBlank String realmName, + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + List entities = auditLogRepository.search(realmName, null, dateDebut, dateFin, + typeAction.name(), null, page, + pageSize); + return auditLogMapper.toDTOList(entities); } @Override public List findByRealm(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - if (logToDatabase) { - List entities = AuditLogEntity.findByRealm(realmName); - return auditLogMapper.toDTOList(entities); - } - return searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize); + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + List entities = auditLogRepository.search(realmName, null, dateDebut, dateFin, null, null, page, + pageSize); + return auditLogMapper.toDTOList(entities); } @Override public List findFailures(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - if (logToDatabase) { - return searchLogsFromDatabase(null, dateDebut, dateFin, null, null, false, page, pageSize); - } - return searchLogsFromCache(null, dateDebut, dateFin, null, null, false, page, pageSize); + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + List entities = auditLogRepository.search(realmName, null, dateDebut, dateFin, null, false, + page, + pageSize); + return auditLogMapper.toDTOList(entities); } @Override public List findCriticalActions(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin, - int page, - int pageSize) { - List allLogs = logToDatabase ? - searchLogsFromDatabase(null, dateDebut, dateFin, null, null, null, page, pageSize) : - searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize); - - return allLogs.stream() - .filter(log -> { - TypeActionAudit type = log.getTypeAction(); - return type == TypeActionAudit.USER_DELETE || - type == TypeActionAudit.ROLE_DELETE || - type == TypeActionAudit.SESSION_REVOKE_ALL; - }) - .collect(Collectors.toList()); + LocalDateTime dateDebut, + LocalDateTime dateFin, + int page, + int pageSize) { + List entities = auditLogRepository.search(realmName, null, dateDebut, dateFin, null, false, + page, pageSize); + return auditLogMapper.toDTOList(entities); } - // ==================== MÉTHODES STATISTIQUES ==================== - @Override + @SuppressWarnings("unchecked") public Map countByActionType(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin) { - return getActionStatistics(dateDebut, dateFin); - } - - @Override - public Map countByActeur(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin) { - return getUserActivityStatistics(dateDebut, dateFin); - } - - @Override - public Map countSuccessVsFailure(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin) { - long successCount = getSuccessCount(dateDebut, dateFin); - long failureCount = getFailureCount(dateDebut, dateFin); - - Map result = new java.util.HashMap<>(); - result.put("success", successCount); - result.put("failure", failureCount); + LocalDateTime dateDebut, + LocalDateTime dateFin) { + StringBuilder sql = new StringBuilder("SELECT action, COUNT(*) AS cnt FROM audit_logs WHERE realm_name = :realmName"); + if (dateDebut != null) sql.append(" AND timestamp >= :dateDebut"); + if (dateFin != null) sql.append(" AND timestamp <= :dateFin"); + sql.append(" GROUP BY action"); + var query = entityManager.createNativeQuery(sql.toString()) + .setParameter("realmName", realmName); + if (dateDebut != null) query.setParameter("dateDebut", dateDebut); + if (dateFin != null) query.setParameter("dateFin", dateFin); + List rows = query.getResultList(); + Map result = new HashMap<>(); + for (Object[] row : rows) { + String actionStr = (String) row[0]; + Long count = ((Number) row[1]).longValue(); + try { + result.put(TypeActionAudit.valueOf(actionStr), count); + } catch (IllegalArgumentException e) { + log.debug("TypeActionAudit inconnu ignoré: {}", actionStr); + } + } return result; } @Override - public Map getAuditStatistics(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin) { - Map stats = new java.util.HashMap<>(); - - long total = logToDatabase ? - AuditLogEntity.findByPeriod(dateDebut, dateFin).size() : - auditLogsCache.values().stream() - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { - return false; - } - if (dateFin != null && log.getDateAction().isAfter(dateFin)) { - return false; - } - return true; - }) - .count(); - - stats.put("total", total); - stats.put("success", getSuccessCount(dateDebut, dateFin)); - stats.put("failure", getFailureCount(dateDebut, dateFin)); - stats.put("byActionType", countByActionType(realmName, dateDebut, dateFin)); - stats.put("byActeur", countByActeur(realmName, dateDebut, dateFin)); - return stats; + @SuppressWarnings("unchecked") + public Map countByActeur(@NotBlank String realmName, + LocalDateTime dateDebut, + LocalDateTime dateFin) { + StringBuilder sql = new StringBuilder("SELECT auteur_action, COUNT(*) AS cnt FROM audit_logs WHERE realm_name = :realmName"); + if (dateDebut != null) sql.append(" AND timestamp >= :dateDebut"); + if (dateFin != null) sql.append(" AND timestamp <= :dateFin"); + sql.append(" GROUP BY auteur_action ORDER BY COUNT(*) DESC LIMIT 10"); + var query = entityManager.createNativeQuery(sql.toString()) + .setParameter("realmName", realmName); + if (dateDebut != null) query.setParameter("dateDebut", dateDebut); + if (dateFin != null) query.setParameter("dateFin", dateFin); + List rows = query.getResultList(); + Map result = new HashMap<>(); + for (Object[] row : rows) { + result.put((String) row[0], ((Number) row[1]).longValue()); + } + return result; } - // ==================== EXPORT / PURGE ==================== + @Override + @SuppressWarnings("unchecked") + public Map countSuccessVsFailure(@NotBlank String realmName, + LocalDateTime dateDebut, + LocalDateTime dateFin) { + StringBuilder sql = new StringBuilder("SELECT success, COUNT(*) AS cnt FROM audit_logs WHERE realm_name = :realmName"); + if (dateDebut != null) sql.append(" AND timestamp >= :dateDebut"); + if (dateFin != null) sql.append(" AND timestamp <= :dateFin"); + sql.append(" GROUP BY success"); + var query = entityManager.createNativeQuery(sql.toString()) + .setParameter("realmName", realmName); + if (dateDebut != null) query.setParameter("dateDebut", dateDebut); + if (dateFin != null) query.setParameter("dateFin", dateFin); + List rows = query.getResultList(); + Map result = new HashMap<>(); + result.put("success", 0L); + result.put("failure", 0L); + for (Object[] row : rows) { + Boolean success = (Boolean) row[0]; + Long count = ((Number) row[1]).longValue(); + result.put(Boolean.TRUE.equals(success) ? "success" : "failure", count); + } + return result; + } @Override public String exportToCSV(@NotBlank String realmName, - LocalDateTime dateDebut, - LocalDateTime dateFin) { - List csvLines = exportLogsToCSV(dateDebut, dateFin); - return String.join("\n", csvLines); + LocalDateTime dateDebut, + LocalDateTime dateFin) { + List entities = auditLogRepository.search(realmName, null, dateDebut, dateFin, null, null, 0, Integer.MAX_VALUE); + List logs = auditLogMapper.toDTOList(entities); + StringBuilder csv = new StringBuilder(); + csv.append("id;typeAction;acteur;realmName;ressourceType;ressourceId;succes;dateAction;message\n"); + for (AuditLogDTO dto : logs) { + csv.append(escapeCsv(dto.getId())); + csv.append(";"); + csv.append(escapeCsv(dto.getTypeAction() != null ? dto.getTypeAction().name() : "")); + csv.append(";"); + csv.append(escapeCsv(dto.getActeurUsername())); + csv.append(";"); + csv.append(escapeCsv(dto.getRealmName())); + csv.append(";"); + csv.append(escapeCsv(dto.getRessourceType())); + csv.append(";"); + csv.append(escapeCsv(dto.getRessourceId())); + csv.append(";"); + csv.append(dto.getSuccess() != null && dto.getSuccess() ? "true" : "false"); + csv.append(";"); + csv.append(dto.getDateAction() != null ? dto.getDateAction().toString() : ""); + csv.append(";"); + csv.append(escapeCsv(dto.getErrorMessage() != null ? dto.getErrorMessage() : (dto.getDescription() != null ? dto.getDescription() : ""))); + csv.append("\n"); + } + return csv.toString(); + } + + private static String escapeCsv(String value) { + if (value == null) return ""; + if (value.contains(";") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; } @Override @Transactional public long purgeOldLogs(@NotNull LocalDateTime dateLimite) { - long purgedCount = 0; - - // Purge en base de données si activé - if (logToDatabase) { - purgedCount = AuditLogEntity.deleteOlderThan(dateLimite); - log.info("Supprimé {} logs d'audit de la base de données avant {}", purgedCount, dateLimite); - } - - // Purge du cache mémoire - long beforeCacheCount = auditLogsCache.size(); - auditLogsCache.entrySet().removeIf(entry -> - entry.getValue().getDateAction().isBefore(dateLimite) - ); - long cacheRemoved = beforeCacheCount - auditLogsCache.size(); - - log.info("Supprimé {} logs du cache mémoire avant {}", cacheRemoved, dateLimite); - - return purgedCount + cacheRemoved; + return auditLogRepository.delete("timestamp < ?1", dateLimite); } - // ==================== MÉTHODES PRIVÉES ==================== + @Override + public Map getAuditStatistics(@NotBlank String realmName, + LocalDateTime dateDebut, + LocalDateTime dateFin) { + Map stats = new java.util.HashMap<>(); + stats.put("total", auditLogRepository.count("realmName", realmName)); + return stats; + } + + // ==================== Méthodes utilitaires ==================== /** - * Recherche les logs depuis le cache mémoire. - */ - private List searchLogsFromCache(String acteurUsername, LocalDateTime dateDebut, - LocalDateTime dateFin, TypeActionAudit typeAction, - String ressourceType, Boolean succes, - int page, int pageSize) { - log.debug("Recherche logs depuis cache mémoire"); - - return auditLogsCache.values().stream() - .filter(log -> applyFilters(log, acteurUsername, dateDebut, dateFin, typeAction, ressourceType, succes)) - .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) - .skip((long) page * pageSize) - .limit(pageSize) - .collect(Collectors.toList()); - } - - /** - * Recherche les logs depuis la base de données PostgreSQL. - */ - private List searchLogsFromDatabase(String acteurUsername, LocalDateTime dateDebut, - LocalDateTime dateFin, TypeActionAudit typeAction, - String ressourceType, Boolean succes, - int page, int pageSize) { - log.debug("Recherche logs depuis base de données"); - - List entities; - - // Optimisation: utiliser les requêtes spécialisées si possible - if (acteurUsername != null && typeAction == null && ressourceType == null) { - entities = AuditLogEntity.findByAuteur(acteurUsername); - } else if (typeAction != null && acteurUsername == null && ressourceType == null) { - entities = AuditLogEntity.findByAction(typeAction); - } else if (dateDebut != null && dateFin != null) { - entities = AuditLogEntity.findByPeriod(dateDebut, dateFin); - } else { - entities = AuditLogEntity.listAll(); - } - - return entities.stream() - .map(auditLogMapper::toDTO) - .filter(log -> applyFilters(log, acteurUsername, dateDebut, dateFin, typeAction, ressourceType, succes)) - .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) - .skip((long) page * pageSize) - .limit(pageSize) - .collect(Collectors.toList()); - } - - /** - * Applique les filtres de recherche à un log. - */ - private boolean applyFilters(AuditLogDTO log, String acteurUsername, LocalDateTime dateDebut, - LocalDateTime dateFin, TypeActionAudit typeAction, - String ressourceType, Boolean succes) { - if (acteurUsername != null && !"*".equals(acteurUsername) && - !acteurUsername.equals(log.getActeurUsername())) { - return false; - } - - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { - return false; - } - - if (dateFin != null && log.getDateAction().isAfter(dateFin)) { - return false; - } - - if (typeAction != null && !typeAction.equals(log.getTypeAction())) { - return false; - } - - if (ressourceType != null && !ressourceType.equals(log.getRessourceType())) { - return false; - } - - if (succes != null && succes != log.isSuccessful()) { - return false; - } - - return true; - } - - /** - * Nettoie les entrées les plus anciennes du cache. - */ - private void cleanOldestCacheEntries() { - int toRemove = auditLogsCache.size() - (cacheSize * 90 / 100); // Garder 90% - - if (toRemove > 0) { - List oldestKeys = auditLogsCache.entrySet().stream() - .sorted((a, b) -> a.getValue().getDateAction().compareTo(b.getValue().getDateAction())) - .limit(toRemove) - .map(Map.Entry::getKey) - .collect(Collectors.toList()); - - oldestKeys.forEach(auditLogsCache::remove); - log.debug("Nettoyé {} entrées du cache d'audit", oldestKeys.size()); - } - } - - // Méthodes helpers (statistiques) - private Map getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) { - if (logToDatabase) { - List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin); - return entities.stream() - .collect(Collectors.groupingBy(AuditLogEntity::getAction, Collectors.counting())); - } - - return auditLogsCache.values().stream() - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false; - if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false; - return true; - }) - .collect(Collectors.groupingBy(AuditLogDTO::getTypeAction, Collectors.counting())); - } - - private Map getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) { - if (logToDatabase) { - List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin); - return entities.stream() - .collect(Collectors.groupingBy(AuditLogEntity::getAuteurAction, Collectors.counting())); - } - - return auditLogsCache.values().stream() - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false; - if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false; - return true; - }) - .collect(Collectors.groupingBy(AuditLogDTO::getActeurUsername, Collectors.counting())); - } - - private long getFailureCount(LocalDateTime dateDebut, LocalDateTime dateFin) { - if (logToDatabase) { - return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream() - .filter(e -> !e.getSuccess()) - .count(); - } - - return auditLogsCache.values().stream() - .filter(log -> !log.isSuccessful()) - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false; - if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false; - return true; - }) - .count(); - } - - private long getSuccessCount(LocalDateTime dateDebut, LocalDateTime dateFin) { - if (logToDatabase) { - return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream() - .filter(AuditLogEntity::getSuccess) - .count(); - } - - return auditLogsCache.values().stream() - .filter(AuditLogDTO::isSuccessful) - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false; - if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false; - return true; - }) - .count(); - } - - private List exportLogsToCSV(LocalDateTime dateDebut, LocalDateTime dateFin) { - log.info("Export CSV des logs d'audit entre {} et {}", dateDebut, dateFin); - - List csvLines = new ArrayList<>(); - csvLines.add("ID,Date Action,Acteur,Type Action,Ressource Type,Ressource ID,Succès,Adresse IP,Détails,Message Erreur"); - - List logs; - if (logToDatabase) { - List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin); - logs = auditLogMapper.toDTOList(entities); - } else { - logs = auditLogsCache.values().stream() - .filter(log -> { - if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false; - if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false; - return true; - }) - .sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction())) - .collect(Collectors.toList()); - } - - logs.forEach(log -> { - String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"", - log.getId(), - log.getDateAction(), - log.getActeurUsername(), - log.getTypeAction(), - log.getRessourceType(), - log.getRessourceId(), - log.isSuccessful(), - log.getIpAddress() != null ? log.getIpAddress() : "", - log.getDescription() != null ? log.getDescription().replace("\"", "\"\"") : "", - log.getErrorMessage() != null ? log.getErrorMessage().replace("\"", "\"\"") : "" - ); - csvLines.add(csvLine); - }); - - log.info("Export CSV terminé: {} lignes", csvLines.size() - 1); - return csvLines; - } - - // ==================== MÉTHODES UTILITAIRES ==================== - - /** - * Retourne le nombre total de logs (cache + DB). + * Retourne le nombre total de logs (Utilisé par les tests) */ public long getTotalCount() { - if (logToDatabase) { - return AuditLogEntity.count(); - } - return auditLogsCache.size(); + return auditLogRepository.count(); } /** - * Vide tous les logs (ATTENTION: à utiliser uniquement en développement). + * Vide tous les logs (Utilisé par les tests) */ @Transactional public void clearAll() { - log.warn("ATTENTION: Suppression de tous les logs d'audit"); - - if (logToDatabase) { - AuditLogEntity.deleteAll(); - log.warn("Supprimé tous les logs de la base de données"); - } - - auditLogsCache.clear(); - log.warn("Vidé le cache mémoire"); + log.warn("ATTENTION: Suppression de tous les logs d'audit en base"); + auditLogRepository.deleteAll(); } } diff --git a/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java index f135479..9a33c18 100644 --- a/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java +++ b/src/main/java/dev/lions/user/manager/service/impl/RoleServiceImpl.java @@ -221,7 +221,7 @@ public class RoleServiceImpl implements RoleService { try { // Vérifier que le realm existe if (!keycloakAdminClient.realmExists(realmName)) { - log.warn("Le realm {} n'existe pas", realmName); + log.error("Le realm {} n'existe pas", realmName); throw new IllegalArgumentException("Le realm '" + realmName + "' n'existe pas"); } @@ -232,19 +232,7 @@ public class RoleServiceImpl implements RoleService { log.info("Récupération réussie: {} rôles trouvés dans le realm {}", roleReps.size(), realmName); return RoleMapper.toDTOList(roleReps, realmName, TypeRole.REALM_ROLE); - } catch (NotFoundException e) { - log.warn("Realm {} non trouvé (404): {}", realmName, e.getMessage()); - throw new IllegalArgumentException("Le realm '" + realmName + "' n'existe pas", e); } catch (Exception e) { - // Vérifier si c'est une erreur 404 dans le message - String errorMessage = e.getMessage(); - if (errorMessage != null && (errorMessage.contains("404") || - errorMessage.contains("Server response is: 404") || - errorMessage.contains("Not Found"))) { - log.warn("Realm {} non trouvé (404 détecté dans l'erreur): {}", realmName, errorMessage); - throw new IllegalArgumentException("Le realm '" + realmName + "' n'existe pas", e); - } - log.error("Erreur lors de la récupération des rôles realm du realm {}: {}", realmName, e.getMessage(), e); throw new RuntimeException("Erreur lors de la récupération des rôles realm: " + e.getMessage(), e); } diff --git a/src/main/java/dev/lions/user/manager/service/impl/SyncServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/SyncServiceImpl.java index 6463e2c..52960e3 100644 --- a/src/main/java/dev/lions/user/manager/service/impl/SyncServiceImpl.java +++ b/src/main/java/dev/lions/user/manager/service/impl/SyncServiceImpl.java @@ -1,216 +1,389 @@ package dev.lions.user.manager.service.impl; -import dev.lions.user.manager.client.KeycloakAdminClient; -import dev.lions.user.manager.dto.role.RoleDTO; -import dev.lions.user.manager.dto.sync.HealthStatusDTO; -import dev.lions.user.manager.dto.sync.SyncResultDTO; -import dev.lions.user.manager.dto.user.UserDTO; -import dev.lions.user.manager.enums.role.TypeRole; -import dev.lions.user.manager.mapper.RoleMapper; -import dev.lions.user.manager.mapper.UserMapper; +import dev.lions.user.manager.server.impl.entity.SyncHistoryEntity; +import dev.lions.user.manager.server.impl.entity.SyncedRoleEntity; +import dev.lions.user.manager.server.impl.entity.SyncedUserEntity; +import dev.lions.user.manager.server.impl.interceptor.Logged; +import dev.lions.user.manager.server.impl.repository.SyncHistoryRepository; +import dev.lions.user.manager.server.impl.repository.SyncedRoleRepository; +import dev.lions.user.manager.server.impl.repository.SyncedUserRepository; import dev.lions.user.manager.service.SyncService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotBlank; import lombok.extern.slf4j.Slf4j; +import dev.lions.user.manager.client.KeycloakAdminClient; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.keycloak.admin.client.Keycloak; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import java.util.Set; -/** - * Implémentation du service de synchronisation avec Keycloak - * - * Ce service permet de: - * - Synchroniser les utilisateurs depuis Keycloak - * - Synchroniser les rôles depuis Keycloak - * - Vérifier la cohérence des données - * - Effectuer des health checks sur Keycloak - */ @ApplicationScoped @Slf4j public class SyncServiceImpl implements SyncService { + @Inject + Keycloak keycloak; + @Inject KeycloakAdminClient keycloakAdminClient; + @Inject + SyncHistoryRepository syncHistoryRepository; + + // Repositories optionnels pour la persistance locale des snapshots. + // Ils sont marqués @Inject mais l'utilisation dans le code est protégée + // par des checks null pour ne pas casser les tests existants. + @Inject + SyncedUserRepository syncedUserRepository; + + @Inject + SyncedRoleRepository syncedRoleRepository; + + @ConfigProperty(name = "lions.keycloak.server-url") + String keycloakServerUrl; + @Override + @Transactional + @Logged(action = "SYNC_USERS", resource = "REALM") public int syncUsersFromRealm(@NotBlank String realmName) { log.info("Synchronisation des utilisateurs depuis le realm: {}", realmName); + LocalDateTime start = LocalDateTime.now(); + int count = 0; + String status = "SUCCESS"; + String errorMessage = null; try { - List userReps = keycloakAdminClient.getInstance() - .realm(realmName) - .users() - .list(); + List users = keycloak.realm(realmName).users().list(); + count = users.size(); + + // Persister un snapshot minimal des utilisateurs dans la base locale si le + // repository est disponible. + if (syncedUserRepository != null && !users.isEmpty()) { + List snapshots = users.stream() + .map(user -> { + SyncedUserEntity entity = new SyncedUserEntity(); + entity.setRealmName(realmName); + entity.setKeycloakId(user.getId()); + entity.setUsername(user.getUsername()); + entity.setEmail(user.getEmail()); + entity.setEnabled(user.isEnabled()); + entity.setEmailVerified(user.isEmailVerified()); + + if (user.getCreatedTimestamp() != null) { + LocalDateTime createdAt = LocalDateTime.ofInstant( + Instant.ofEpochMilli(user.getCreatedTimestamp()), + ZoneOffset.UTC); + entity.setCreatedAt(createdAt); + } + return entity; + }) + .toList(); + + syncedUserRepository.replaceForRealm(realmName, snapshots); + log.info("Persisted {} synced user snapshots for realm {}", snapshots.size(), realmName); + } - int count = userReps.size(); log.info("✅ {} utilisateurs synchronisés depuis le realm {}", count, realmName); - return count; } catch (Exception e) { log.error("❌ Erreur lors de la synchronisation des utilisateurs depuis le realm {}", realmName, e); - throw new RuntimeException("Erreur lors de la synchronisation des utilisateurs", e); + status = "FAILURE"; + errorMessage = e.getMessage(); + throw new RuntimeException("Erreur de synchronisation utilisateurs: " + e.getMessage(), e); + } finally { + recordSyncHistory(realmName, "USER", status, count, start, errorMessage); } + return count; } @Override + @Transactional + @Logged(action = "SYNC_ROLES", resource = "REALM") public int syncRolesFromRealm(@NotBlank String realmName) { log.info("Synchronisation des rôles depuis le realm: {}", realmName); + LocalDateTime start = LocalDateTime.now(); + int count = 0; + String status = "SUCCESS"; + String errorMessage = null; try { - List roleReps = keycloakAdminClient.getInstance() - .realm(realmName) - .roles() - .list(); + List roles = keycloak.realm(realmName).roles().list(); + count = roles.size(); + + // Persister un snapshot minimal des rôles dans la base locale si le repository + // est disponible. + if (syncedRoleRepository != null && !roles.isEmpty()) { + List snapshots = roles.stream() + .map(role -> { + SyncedRoleEntity entity = new SyncedRoleEntity(); + entity.setRealmName(realmName); + entity.setRoleName(role.getName()); + entity.setDescription(role.getDescription()); + return entity; + }) + .toList(); + + syncedRoleRepository.replaceForRealm(realmName, snapshots); + log.info("Persisted {} synced role snapshots for realm {}", snapshots.size(), realmName); + } - int count = roleReps.size(); log.info("✅ {} rôles synchronisés depuis le realm {}", count, realmName); - return count; } catch (Exception e) { log.error("❌ Erreur lors de la synchronisation des rôles depuis le realm {}", realmName, e); - throw new RuntimeException("Erreur lors de la synchronisation des rôles", e); + status = "FAILURE"; + errorMessage = e.getMessage(); + throw new RuntimeException("Erreur de synchronisation rôles: " + e.getMessage(), e); + } finally { + recordSyncHistory(realmName, "ROLE", status, count, start, errorMessage); } + return count; } @Override + @Transactional + @Logged(action = "REALM_SYNC", resource = "SYSTEM") public Map syncAllRealms() { - log.info("Synchronisation de tous les realms"); - - Map results = new java.util.HashMap<>(); - + Map result = new HashMap<>(); try { - // Lister tous les realms - List realms = - keycloakAdminClient.getInstance().realms().findAll(); - - for (org.keycloak.representations.idm.RealmRepresentation realm : realms) { - String realmName = realm.getRealm(); - try { - int usersCount = syncUsersFromRealm(realmName); - int rolesCount = syncRolesFromRealm(realmName); - results.put(realmName, usersCount + rolesCount); - } catch (Exception e) { - log.error("Erreur lors de la synchronisation du realm {}", realmName, e); - results.put(realmName, 0); + // getAllRealms() utilise un HttpClient raw avec ObjectMapper(FAIL_ON_UNKNOWN_PROPERTIES=false) + // pour éviter les erreurs de désérialisation de RealmRepresentation avec Keycloak 26+ + List realmNames = keycloakAdminClient.getAllRealms(); + + for (String realmName : realmNames) { + if (realmName == null || realmName.isBlank()) { + continue; } + + log.info("Synchronisation complète du realm {}", realmName); + int totalForRealm = 0; + try { + int users = syncUsersFromRealm(realmName); + int roles = syncRolesFromRealm(realmName); + totalForRealm = users + roles; + log.info("✅ Realm {} synchronisé (users={}, roles={})", realmName, users, roles); + } catch (Exception e) { + log.error("❌ Erreur lors de la synchronisation du realm {}", realmName, e); + // On enregistre quand même le realm dans le résultat avec 0 éléments traités + totalForRealm = 0; + } + result.put(realmName, totalForRealm); } } catch (Exception e) { - log.error("Erreur lors de la synchronisation de tous les realms", e); + log.error("❌ Erreur lors de la récupération de la liste des realms pour synchronisation globale", e); + // En cas d'erreur globale, on retourne simplement une map vide (aucune + // approximation) } - - return results; + return result; } @Override public Map checkDataConsistency(@NotBlank String realmName) { - log.info("Vérification de la cohérence des données pour le realm: {}", realmName); + Map report = new HashMap<>(); + report.put("realmName", realmName); - Map report = new java.util.HashMap<>(); - try { - // Pour l'instant, on retourne juste un rapport basique - // En production, on comparerait avec un cache local - report.put("realmName", realmName); - report.put("status", "ok"); - report.put("message", "Cohérence vérifiée"); + // Données actuelles dans Keycloak + List kcUsers = keycloak.realm(realmName).users().list(); + List kcRoles = keycloak.realm(realmName).roles().list(); + + // Snapshots locaux + List localUsers = syncedUserRepository.list("realmName", realmName); + List localRoles = syncedRoleRepository.list("realmName", realmName); + + // Comparaison exacte des identifiants utilisateurs + Set kcUserIds = kcUsers.stream() + .map(UserRepresentation::getId) + .filter(id -> id != null && !id.isBlank()) + .collect(java.util.stream.Collectors.toSet()); + + Set localUserIds = localUsers.stream() + .map(SyncedUserEntity::getKeycloakId) + .filter(id -> id != null && !id.isBlank()) + .collect(java.util.stream.Collectors.toSet()); + + Set missingUsersInLocal = new HashSet<>(kcUserIds); + missingUsersInLocal.removeAll(localUserIds); + + Set missingUsersInKeycloak = new HashSet<>(localUserIds); + missingUsersInKeycloak.removeAll(kcUserIds); + + // Comparaison exacte des noms de rôles + Set kcRoleNames = kcRoles.stream() + .map(RoleRepresentation::getName) + .filter(name -> name != null && !name.isBlank()) + .collect(java.util.stream.Collectors.toSet()); + + Set localRoleNames = localRoles.stream() + .map(SyncedRoleEntity::getRoleName) + .filter(name -> name != null && !name.isBlank()) + .collect(java.util.stream.Collectors.toSet()); + + Set missingRolesInLocal = new HashSet<>(kcRoleNames); + missingRolesInLocal.removeAll(localRoleNames); + + Set missingRolesInKeycloak = new HashSet<>(localRoleNames); + missingRolesInKeycloak.removeAll(kcRoleNames); + + boolean usersOk = missingUsersInLocal.isEmpty() && missingUsersInKeycloak.isEmpty(); + boolean rolesOk = missingRolesInLocal.isEmpty() && missingRolesInKeycloak.isEmpty(); + + report.put("status", (usersOk && rolesOk) ? "OK" : "MISMATCH"); + + report.put("usersKeycloakCount", kcUserIds.size()); + report.put("usersLocalCount", localUserIds.size()); + report.put("missingUsersInLocal", missingUsersInLocal); + report.put("missingUsersInKeycloak", missingUsersInKeycloak); + + report.put("rolesKeycloakCount", kcRoleNames.size()); + report.put("rolesLocalCount", localRoleNames.size()); + report.put("missingRolesInLocal", missingRolesInLocal); + report.put("missingRolesInKeycloak", missingRolesInKeycloak); } catch (Exception e) { - log.error("Erreur lors de la vérification de cohérence pour le realm {}", realmName, e); - report.put("status", "error"); - report.put("message", e.getMessage()); + log.error("❌ Erreur lors du contrôle de cohérence des données pour le realm {}", realmName, e); + report.put("status", "ERROR"); + report.put("error", e.getMessage()); } return report; } @Override + @Transactional public Map forceSyncRealm(@NotBlank String realmName) { - log.info("Synchronisation forcée du realm: {}", realmName); - - Map stats = new java.util.HashMap<>(); - long startTime = System.currentTimeMillis(); - + Map result = new HashMap<>(); try { - int usersCount = syncUsersFromRealm(realmName); - int rolesCount = syncRolesFromRealm(realmName); - - stats.put("realmName", realmName); - stats.put("usersCount", usersCount); - stats.put("rolesCount", rolesCount); - stats.put("success", true); - stats.put("durationMs", System.currentTimeMillis() - startTime); + int users = syncUsersFromRealm(realmName); + int roles = syncRolesFromRealm(realmName); + result.put("usersSynced", users); + result.put("rolesSynced", roles); + result.put("status", "SUCCESS"); } catch (Exception e) { - log.error("Erreur lors de la synchronisation forcée du realm {}", realmName, e); - stats.put("success", false); - stats.put("error", e.getMessage()); - stats.put("durationMs", System.currentTimeMillis() - startTime); + result.put("status", "FAILURE"); + result.put("error", e.getMessage()); } - - return stats; + return result; } @Override public Map getLastSyncStatus(@NotBlank String realmName) { - log.debug("Récupération du statut de la dernière synchronisation pour le realm: {}", realmName); + List history = syncHistoryRepository.findLatestByRealm(realmName, 1); + if (history.isEmpty()) { + return Collections.singletonMap("status", "NEVER_SYNCED"); + } + SyncHistoryEntity lastSync = history.get(0); - Map status = new java.util.HashMap<>(); - status.put("realmName", realmName); - status.put("lastSyncTime", System.currentTimeMillis()); // En production, récupérer depuis un cache - status.put("status", "completed"); - - return status; + Map statusMap = new HashMap<>(); // Utilisation de HashMap pour permettre nulls si besoin + statusMap.put("lastSyncDate", lastSync.getSyncDate()); + statusMap.put("status", lastSync.getStatus()); + statusMap.put("type", lastSync.getSyncType()); + statusMap.put("itemsProcessed", lastSync.getItemsProcessed()); + return statusMap; } @Override public boolean isKeycloakAvailable() { - log.debug("Vérification de la disponibilité de Keycloak"); - try { - // Test de connexion en récupérant les informations du serveur - keycloakAdminClient.getInstance().serverInfo().getInfo(); - log.debug("✅ Keycloak est accessible et fonctionne"); + // getAllRealms() utilise un HttpClient raw : pas de désérialisation de RealmRepresentation + // donc pas d'erreur UnrecognizedPropertyException avec Keycloak 26+ + keycloakAdminClient.getAllRealms(); return true; } catch (Exception e) { - log.error("❌ Keycloak n'est pas accessible: {}", e.getMessage()); + log.warn("Keycloak availability check failed: {}", e.getMessage()); return false; } } @Override public Map getKeycloakHealthInfo() { - log.info("Récupération du statut de santé complet de Keycloak"); - - Map healthInfo = new java.util.HashMap<>(); - healthInfo.put("timestamp", System.currentTimeMillis()); - + Map health = new HashMap<>(); try { - // Test connexion principale - var serverInfo = keycloakAdminClient.getInstance().serverInfo().getInfo(); - healthInfo.put("keycloakAccessible", true); - healthInfo.put("keycloakVersion", serverInfo.getSystemInfo().getVersion()); - - // Test des realms (on essaie juste de lister) - try { - int realmsCount = keycloakAdminClient.getInstance().realms().findAll().size(); - healthInfo.put("realmsAccessible", true); - healthInfo.put("realmsCount", realmsCount); - } catch (Exception e) { - healthInfo.put("realmsAccessible", false); - log.warn("Impossible d'accéder aux realms: {}", e.getMessage()); - } - - healthInfo.put("overallHealthy", true); - log.info("✅ Keycloak est en bonne santé - Version: {}, Realms: {}", - healthInfo.get("keycloakVersion"), healthInfo.get("realmsCount")); - + var info = keycloak.serverInfo().getInfo(); + health.put("status", "UP"); + health.put("version", info.getSystemInfo().getVersion()); + health.put("serverTime", info.getSystemInfo().getServerTime()); } catch (Exception e) { - healthInfo.put("keycloakAccessible", false); - healthInfo.put("overallHealthy", false); - healthInfo.put("errorMessage", e.getMessage()); - log.error("❌ Keycloak n'est pas accessible: {}", e.getMessage()); + log.debug("serverInfo().getInfo() failed, trying raw HTTP fallback: {}", e.getMessage()); + fetchVersionViaHttp(health); } + return health; + } - return healthInfo; + private void fetchVersionViaHttp(Map health) { + try { + String token = keycloak.tokenManager().getAccessTokenString(); + var client = java.net.http.HttpClient.newHttpClient(); + var request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(keycloakServerUrl + "/admin/serverinfo")) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .GET().build(); + var response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + String body = response.body(); + health.put("status", "UP"); + int sysInfoIdx = body.indexOf("\"systemInfo\""); + if (sysInfoIdx >= 0) { + extractJsonStringField(body, "version", sysInfoIdx) + .ifPresent(v -> health.put("version", v)); + extractJsonStringField(body, "serverTime", sysInfoIdx) + .ifPresent(v -> health.put("serverTime", v)); + } + if (!health.containsKey("version")) { + health.put("version", "UP (version non parsée)"); + } + } else { + health.put("status", "UP"); + health.put("version", "UP (serverinfo HTTP " + response.statusCode() + ")"); + } + } catch (Exception ex) { + log.error("Fallback HTTP serverinfo also failed: {}", ex.getMessage()); + health.put("status", "DOWN"); + health.put("error", ex.getMessage()); + } + } + + private java.util.Optional extractJsonStringField(String json, String field, int searchFrom) { + String pattern = "\"" + field + "\""; + int idx = json.indexOf(pattern, searchFrom); + if (idx < 0) return java.util.Optional.empty(); + int colonIdx = json.indexOf(':', idx + pattern.length()); + if (colonIdx < 0) return java.util.Optional.empty(); + int startQuote = json.indexOf('"', colonIdx + 1); + if (startQuote < 0) return java.util.Optional.empty(); + int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) return java.util.Optional.empty(); + return java.util.Optional.of(json.substring(startQuote + 1, endQuote)); + } + + // Helper method to record history + private void recordSyncHistory(String realmName, String type, String status, int count, LocalDateTime start, + String errorMessage) { + try { + SyncHistoryEntity history = new SyncHistoryEntity(); + history.setRealmName(realmName); + history.setSyncType(type); + history.setStatus(status); + history.setItemsProcessed(count); + history.setSyncDate(LocalDateTime.now()); + history.setDurationMs(ChronoUnit.MILLIS.between(start, LocalDateTime.now())); + history.setErrorMessage(errorMessage); + + // Persist the history entity + syncHistoryRepository.persist(history); + } catch (Exception e) { + log.error("Failed to record sync history", e); + } } } diff --git a/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java index 335e37f..b381747 100644 --- a/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java +++ b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java @@ -1,19 +1,21 @@ package dev.lions.user.manager.service.impl; +import dev.lions.user.manager.server.impl.interceptor.Logged; + import dev.lions.user.manager.client.KeycloakAdminClient; +import dev.lions.user.manager.dto.importexport.ImportResultDTO; import dev.lions.user.manager.dto.user.UserDTO; import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; import dev.lions.user.manager.dto.user.UserSearchResultDTO; +import dev.lions.user.manager.enums.user.StatutUser; import dev.lions.user.manager.mapper.UserMapper; import dev.lions.user.manager.service.UserService; -import dev.lions.user.manager.service.exception.KeycloakServiceException; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UsersResource; @@ -21,11 +23,9 @@ import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.util.Collections; -import java.util.List; -import java.util.Optional; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; import java.util.stream.Collectors; /** @@ -83,6 +83,23 @@ public class UserServiceImpl implements UserService { // Convertir en DTOs List userDTOs = UserMapper.toDTOList(users, realmName); + // Enrichir avec les rôles si demandé + if (Boolean.TRUE.equals(criteria.getIncludeRoles())) { + for (UserDTO dto : userDTOs) { + try { + List realmRoles = usersResource.get(dto.getId()) + .roles().realmLevel().listAll(); + if (realmRoles != null) { + dto.setRealmRoles(realmRoles.stream() + .map(RoleRepresentation::getName) + .collect(Collectors.toList())); + } + } catch (Exception e) { + log.warn("Impossible de charger les rôles pour l'utilisateur {}: {}", dto.getId(), e.getMessage()); + } + } + } + // Compter le total long totalCount = usersResource.count(); @@ -91,8 +108,7 @@ public class UserServiceImpl implements UserService { } catch (Exception e) { log.error("Erreur lors de la recherche d'utilisateurs", e); - handleConnectionException(e, "recherche d'utilisateurs"); - return null; // Ne sera jamais atteint car handleConnectionException lance une exception + throw new RuntimeException("Impossible de rechercher les utilisateurs", e); } } @@ -135,8 +151,7 @@ public class UserServiceImpl implements UserService { return Optional.empty(); } log.error("Erreur lors de la récupération de l'utilisateur {}", userId, e); - handleConnectionException(e, "récupération de l'utilisateur " + userId); - return Optional.empty(); // Ne sera jamais atteint mais nécessaire pour le compilateur + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); } } @@ -148,11 +163,6 @@ public class UserServiceImpl implements UserService { List users = keycloakAdminClient.getUsers(realmName) .search(username, 0, 1, true); - if (users == null) { - log.warn("Liste d'utilisateurs null retournée pour username {} dans le realm {}", username, realmName); - return Optional.empty(); - } - if (users.isEmpty()) { return Optional.empty(); } @@ -160,8 +170,7 @@ public class UserServiceImpl implements UserService { return Optional.of(UserMapper.toDTO(users.get(0), realmName)); } catch (Exception e) { log.error("Erreur lors de la récupération de l'utilisateur par username {}", username, e); - handleConnectionException(e, "récupération de l'utilisateur par username " + username); - return Optional.empty(); // Ne sera jamais atteint mais nécessaire pour le compilateur + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); } } @@ -173,11 +182,6 @@ public class UserServiceImpl implements UserService { List users = keycloakAdminClient.getUsers(realmName) .searchByEmail(email, true); - if (users == null) { - log.warn("Liste d'utilisateurs null retournée pour email {} dans le realm {}", email, realmName); - return Optional.empty(); - } - if (users.isEmpty()) { return Optional.empty(); } @@ -185,12 +189,12 @@ public class UserServiceImpl implements UserService { return Optional.of(UserMapper.toDTO(users.get(0), realmName)); } catch (Exception e) { log.error("Erreur lors de la récupération de l'utilisateur par email {}", email, e); - handleConnectionException(e, "récupération de l'utilisateur par email " + email); - return Optional.empty(); // Ne sera jamais atteint mais nécessaire pour le compilateur + throw new RuntimeException("Impossible de récupérer l'utilisateur", e); } } @Override + @Logged(action = "USER_CREATE", resource = "USER") public UserDTO createUser(@Valid @NotNull UserDTO user, @NotBlank String realmName) { log.info("Création de l'utilisateur {} dans le realm {}", user.getUsername(), realmName); @@ -209,39 +213,10 @@ public class UserServiceImpl implements UserService { // Créer l'utilisateur UsersResource usersResource = keycloakAdminClient.getUsers(realmName); - Response response = usersResource.create(userRep); + var response = usersResource.create(userRep); - // Vérifier si la réponse est null (erreur de connexion) - if (response == null) { - log.error("❌ Réponse null lors de la création de l'utilisateur {} - Service Keycloak indisponible", user.getUsername()); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour créer l'utilisateur: " + user.getUsername()); - } - - // Vérifier le code de statut HTTP - int status = response.getStatus(); - if (status != Response.Status.CREATED.getStatusCode()) { - String errorMessage = "Échec de la création de l'utilisateur"; - if (response.getStatusInfo() != null) { - errorMessage += ": " + response.getStatusInfo(); - } - - // Gérer les différents codes d'erreur HTTP - if (status == 400) { - throw new KeycloakServiceException("Données invalides pour la création de l'utilisateur: " + errorMessage, status); - } else if (status == 409) { - throw new IllegalArgumentException("L'utilisateur existe déjà (conflit détecté par Keycloak)"); - } else if (status == 503 || status == 502) { - throw new KeycloakServiceException.ServiceUnavailableException("Service Keycloak indisponible: " + errorMessage); - } else { - throw new KeycloakServiceException(errorMessage, status); - } - } - - // Vérifier que la location est présente - if (response.getLocation() == null) { - log.error("❌ Location manquante dans la réponse de création pour l'utilisateur {}", user.getUsername()); - throw new KeycloakServiceException("Réponse invalide du service Keycloak: location manquante", status); + if (response.getStatus() != 201) { + throw new RuntimeException("Échec de la création de l'utilisateur: " + response.getStatusInfo()); } // Récupérer l'ID de l'utilisateur créé @@ -260,41 +235,22 @@ public class UserServiceImpl implements UserService { log.info("✅ Utilisateur créé avec succès: {} (ID: {})", user.getUsername(), userId); return UserMapper.toDTO(createdUser, realmName); - } catch (IllegalArgumentException e) { - // Répercuter les erreurs de validation - throw e; - } catch (KeycloakServiceException e) { - // Répercuter les erreurs de service - throw e; } catch (Exception e) { log.error("❌ Erreur lors de la création de l'utilisateur {}", user.getUsername(), e); - handleConnectionException(e, "création de l'utilisateur " + user.getUsername()); - return null; // Ne sera jamais atteint car handleConnectionException lance une exception + throw new RuntimeException("Impossible de créer l'utilisateur", e); } } @Override + @Logged(action = "USER_UPDATE", resource = "USER") public UserDTO updateUser(@NotBlank String userId, @Valid @NotNull UserDTO user, @NotBlank String realmName) { log.info("Mise à jour de l'utilisateur {} dans le realm {}", userId, realmName); try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - // Vérifier si userResource est null (peut arriver si la connexion échoue) - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour mettre à jour l'utilisateur: " + userId); - } - // Récupérer l'utilisateur existant UserRepresentation existingUser = userResource.toRepresentation(); - - if (existingUser == null) { - log.error("❌ UserRepresentation null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de récupérer les données de l'utilisateur depuis Keycloak: " + userId); - } // Mettre à jour les champs if (user.getEmail() != null) { @@ -321,39 +277,26 @@ public class UserServiceImpl implements UserService { // Récupérer l'utilisateur mis à jour UserRepresentation updatedUser = userResource.toRepresentation(); - - if (updatedUser == null) { - log.error("❌ UserRepresentation null après mise à jour pour l'utilisateur {}", userId); - throw new KeycloakServiceException("Impossible de récupérer l'utilisateur mis à jour", 500); - } log.info("✅ Utilisateur mis à jour avec succès: {}", userId); return UserMapper.toDTO(updatedUser, realmName); } catch (NotFoundException e) { log.error("❌ Utilisateur {} non trouvé", userId); - throw new KeycloakServiceException("Utilisateur non trouvé: " + userId, 404, e); - } catch (KeycloakServiceException e) { - throw e; + throw new RuntimeException("Utilisateur non trouvé", e); } catch (Exception e) { log.error("❌ Erreur lors de la mise à jour de l'utilisateur {}", userId, e); - handleConnectionException(e, "mise à jour de l'utilisateur " + userId); - return null; // Ne sera jamais atteint car handleConnectionException lance une exception + throw new RuntimeException("Impossible de mettre à jour l'utilisateur", e); } } @Override + @Logged(action = "USER_DELETE", resource = "USER") public void deleteUser(@NotBlank String userId, @NotBlank String realmName, boolean hardDelete) { log.info("Suppression de l'utilisateur {} dans le realm {} (hard: {})", userId, realmName, hardDelete); try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour supprimer l'utilisateur: " + userId); - } if (hardDelete) { // Suppression définitive @@ -362,11 +305,6 @@ public class UserServiceImpl implements UserService { } else { // Soft delete: désactiver l'utilisateur UserRepresentation user = userResource.toRepresentation(); - if (user == null) { - log.error("❌ UserRepresentation null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de récupérer les données de l'utilisateur depuis Keycloak: " + userId); - } user.setEnabled(false); userResource.update(user); log.info("✅ Utilisateur désactivé (soft delete): {}", userId); @@ -374,78 +312,46 @@ public class UserServiceImpl implements UserService { } catch (NotFoundException e) { log.error("❌ Utilisateur {} non trouvé", userId); - throw new KeycloakServiceException("Utilisateur non trouvé: " + userId, 404, e); - } catch (KeycloakServiceException e) { - throw e; + throw new RuntimeException("Utilisateur non trouvé", e); } catch (Exception e) { log.error("❌ Erreur lors de la suppression de l'utilisateur {}", userId, e); - handleConnectionException(e, "suppression de l'utilisateur " + userId); + throw new RuntimeException("Impossible de supprimer l'utilisateur", e); } } @Override + @Logged(action = "USER_ACTIVATE", resource = "USER") public void activateUser(@NotBlank String userId, @NotBlank String realmName) { log.info("Activation de l'utilisateur {} dans le realm {}", userId, realmName); try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour activer l'utilisateur: " + userId); - } - UserRepresentation user = userResource.toRepresentation(); - - if (user == null) { - log.error("❌ UserRepresentation null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de récupérer les données de l'utilisateur depuis Keycloak: " + userId); - } - user.setEnabled(true); userResource.update(user); log.info("✅ Utilisateur activé: {}", userId); - } catch (KeycloakServiceException e) { - throw e; } catch (Exception e) { log.error("❌ Erreur lors de l'activation de l'utilisateur {}", userId, e); - handleConnectionException(e, "activation de l'utilisateur " + userId); + throw new RuntimeException("Impossible d'activer l'utilisateur", e); } } @Override + @Logged(action = "USER_DEACTIVATE", resource = "USER") public void deactivateUser(@NotBlank String userId, @NotBlank String realmName, String raison) { log.info("Désactivation de l'utilisateur {} dans le realm {} (raison: {})", userId, realmName, raison); try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour désactiver l'utilisateur: " + userId); - } - UserRepresentation user = userResource.toRepresentation(); - - if (user == null) { - log.error("❌ UserRepresentation null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de récupérer les données de l'utilisateur depuis Keycloak: " + userId); - } - user.setEnabled(false); userResource.update(user); log.info("✅ Utilisateur désactivé: {}", userId); - } catch (KeycloakServiceException e) { - throw e; } catch (Exception e) { log.error("❌ Erreur lors de la désactivation de l'utilisateur {}", userId, e); - handleConnectionException(e, "désactivation de l'utilisateur " + userId); + throw new RuntimeException("Impossible de désactiver l'utilisateur", e); } } @@ -458,12 +364,14 @@ public class UserServiceImpl implements UserService { } @Override + @Logged(action = "USER_UNLOCK", resource = "USER") public void unlockUser(@NotBlank String userId, @NotBlank String realmName) { log.info("Déverrouillage de l'utilisateur {} dans le realm {}", userId, realmName); activateUser(userId, realmName); } @Override + @Logged(action = "USER_PASSWORD_RESET", resource = "USER") public void resetPassword(@NotBlank String userId, @NotBlank String realmName, @NotBlank String temporaryPassword, boolean temporary) { log.info("Réinitialisation du mot de passe pour l'utilisateur {} (temporaire: {})", userId, temporary); @@ -477,48 +385,30 @@ public class UserServiceImpl implements UserService { try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour envoyer l'email de vérification: " + userId); - } - userResource.sendVerifyEmail(); log.info("✅ Email de vérification envoyé: {}", userId); - } catch (KeycloakServiceException e) { - throw e; } catch (Exception e) { log.error("❌ Erreur lors de l'envoi de l'email de vérification pour {}", userId, e); - handleConnectionException(e, "envoi de l'email de vérification pour l'utilisateur " + userId); + throw new RuntimeException("Impossible d'envoyer l'email de vérification", e); } } @Override + @Logged(action = "USER_FORCE_LOGOUT", resource = "USER") public int logoutAllSessions(@NotBlank String userId, @NotBlank String realmName) { log.info("Déconnexion de toutes les sessions pour l'utilisateur {}", userId); try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); - - if (userResource == null) { - log.error("❌ UserResource null pour l'utilisateur {} - Service Keycloak indisponible", userId); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour déconnecter les sessions: " + userId); - } - - int sessionsCount = userResource.getUserSessions() != null ? userResource.getUserSessions().size() : 0; + int sessionsCount = userResource.getUserSessions().size(); userResource.logout(); log.info("✅ {} sessions révoquées pour l'utilisateur {}", sessionsCount, userId); return sessionsCount; - } catch (KeycloakServiceException e) { - throw e; } catch (Exception e) { log.error("❌ Erreur lors de la déconnexion des sessions pour {}", userId, e); - handleConnectionException(e, "déconnexion des sessions pour l'utilisateur " + userId); - return 0; // Ne sera jamais atteint car handleConnectionException lance une exception + throw new RuntimeException("Impossible de déconnecter les sessions", e); } } @@ -584,305 +474,228 @@ public class UserServiceImpl implements UserService { @Override public String exportUsersToCSV(@NotNull UserSearchCriteriaDTO criteria) { - log.info("Export des utilisateurs en CSV pour le realm {}", criteria.getRealmName()); + log.info("Export CSV des utilisateurs avec critères: {}", criteria); - // Disable pagination for export to get all users - int originalPageSize = criteria.getPageSize(); - criteria.setPageSize(10000); // Set a large limit or handle pagination loops + try { + // Récupérer tous les utilisateurs correspondant aux critères + UserSearchResultDTO searchResult = searchUsers(criteria); + List users = searchResult.getUsers(); - UserSearchResultDTO result = searchUsers(criteria); - criteria.setPageSize(originalPageSize); // Restore + if (users == null || users.isEmpty()) { + log.warn("Aucun utilisateur trouvé pour l'export CSV"); + return generateCSVHeader(); + } - StringBuilder csv = new StringBuilder(); - csv.append("username,email,firstName,lastName,enabled\n"); + // Générer le CSV + StringBuilder csv = new StringBuilder(); + csv.append(generateCSVHeader()); + csv.append("\n"); - for (UserDTO user : result.getUsers()) { - csv.append(escape(user.getUsername())).append(","); - csv.append(escape(user.getEmail())).append(","); - csv.append(escape(user.getPrenom())).append(","); - csv.append(escape(user.getNom())).append(","); - csv.append(user.getEnabled() != null ? user.getEnabled() : true).append("\n"); + for (UserDTO user : users) { + csv.append(escapeCSVField(user.getUsername() != null ? user.getUsername() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getEmail() != null ? user.getEmail() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getPrenom() != null ? user.getPrenom() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getNom() != null ? user.getNom() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getTelephone() != null ? user.getTelephone() : "")); + csv.append(","); + csv.append(user.getEnabled() != null && user.getEnabled() ? "true" : "false"); + csv.append(","); + csv.append(user.getEmailVerified() != null && user.getEmailVerified() ? "true" : "false"); + csv.append(","); + csv.append(escapeCSVField(user.getStatut() != null ? user.getStatut().name() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getOrganisation() != null ? user.getOrganisation() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getDepartement() != null ? user.getDepartement() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getFonction() != null ? user.getFonction() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getPays() != null ? user.getPays() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getVille() != null ? user.getVille() : "")); + csv.append(","); + csv.append(escapeCSVField(user.getDateCreation() != null + ? user.getDateCreation().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + : "")); + csv.append("\n"); + } + + log.info("✅ Export CSV réussi: {} utilisateur(s) exporté(s)", users.size()); + return csv.toString(); + + } catch (Exception e) { + log.error("❌ Erreur lors de l'export CSV", e); + throw new RuntimeException("Impossible d'exporter les utilisateurs en CSV", e); } + } - return csv.toString(); + private String generateCSVHeader() { + return "username,email,prenom,nom,telephone,enabled,emailVerified,statut,organisation,departement,fonction,pays,ville,dateCreation"; + } + + private String escapeCSVField(String field) { + if (field == null) { + return ""; + } + // Si le champ contient une virgule, des guillemets ou un saut de ligne, + // l'entourer de guillemets + if (field.contains(",") || field.contains("\"") || field.contains("\n")) { + // Échapper les guillemets en les doublant + return "\"" + field.replace("\"", "\"\"") + "\""; + } + return field; } @Override - public dev.lions.user.manager.dto.importexport.ImportResultDTO importUsersFromCSV(@NotBlank String csvContent, @NotBlank String realmName) { - log.info("Import des utilisateurs depuis CSV pour le realm {}", realmName); + @Logged(action = "USER_IMPORT", resource = "USER") + public ImportResultDTO importUsersFromCSV(@NotBlank String csvContent, @NotBlank String realmName) { + log.info("Import CSV de {} lignes pour le realm {}", csvContent.split("\n").length, realmName); - dev.lions.user.manager.dto.importexport.ImportResultDTO result = dev.lions.user.manager.dto.importexport.ImportResultDTO.builder() - .totalLines(0) - .successCount(0) - .errorCount(0) - .errors(new java.util.ArrayList<>()) - .build(); + ImportResultDTO.ImportResultDTOBuilder resultBuilder = ImportResultDTO.builder(); + List errors = new ArrayList<>(); + int successCount = 0; + int lineNumber = 0; - String[] lines = csvContent.split("\\r?\\n"); - int startIndex = 0; + String[] lines = csvContent.split("\n"); + resultBuilder.totalLines(lines.length); - // Skip header if present - if (lines.length > 0 && lines[0].toLowerCase().startsWith("username")) { - startIndex = 1; + // Ignorer la première ligne si c'est l'en-tête + int startLine = 0; + if (lines.length > 0 && lines[0].toLowerCase().contains("username")) { + startLine = 1; } - result.setTotalLines(lines.length - startIndex); - - for (int i = startIndex; i < lines.length; i++) { - int lineNumber = i + 1; + for (int i = startLine; i < lines.length; i++) { + lineNumber = i + 1; String line = lines[i].trim(); if (line.isEmpty()) { - continue; // Ignore empty lines + continue; } try { - // Parse CSV line - String[] parts = parseCSVLine(line); - if (parts.length < 5) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.INVALID_FORMAT) - .message("Nombre de colonnes insuffisant (attendu: 5, trouvé: " + parts.length + ")") - .build()); - continue; - } + // Parser la ligne CSV + String[] fields = parseCSVLine(line); - String username = CsvValidationHelper.clean(parts[0]); - String email = CsvValidationHelper.clean(parts[1]); - String firstName = CsvValidationHelper.clean(parts[2]); - String lastName = CsvValidationHelper.clean(parts[3]); - String enabledStr = CsvValidationHelper.clean(parts[4]); - - // Validate username - String usernameError = CsvValidationHelper.validateUsername(username); - if (usernameError != null) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR) - .field("username") - .message(usernameError) - .build()); - continue; - } - - // Validate email - String emailError = CsvValidationHelper.validateEmail(email); - if (emailError != null) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR) - .field("email") - .message(emailError) - .build()); - continue; - } - - // Validate firstName - String firstNameError = CsvValidationHelper.validateName(firstName, "Prénom"); - if (firstNameError != null) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR) - .field("firstName") - .message(firstNameError) - .build()); - continue; - } - - // Validate lastName - String lastNameError = CsvValidationHelper.validateName(lastName, "Nom"); - if (lastNameError != null) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR) - .field("lastName") - .message(lastNameError) - .build()); - continue; - } - - // Validate enabled - String enabledError = CsvValidationHelper.validateBoolean(enabledStr); - if (enabledError != null) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR) - .field("enabled") - .message(enabledError) - .build()); - continue; - } - - boolean enabled = CsvValidationHelper.parseBoolean(enabledStr); - - // Check if user already exists - try { - java.util.Optional existingUser = getUserByUsername(username, realmName); - if (existingUser.isPresent()) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() + if (fields.length < 4) { + errors.add(ImportResultDTO.ImportErrorDTO.builder() .lineNumber(lineNumber) .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.DUPLICATE_USER) - .field("username") - .message("Utilisateur déjà existant: " + username) + .errorType(ImportResultDTO.ErrorType.INVALID_FORMAT) + .message("Nombre de colonnes insuffisant (minimum 4: username, email, prenom, nom)") .build()); - continue; - } - } catch (Exception e) { - // User doesn't exist, continue with creation + continue; } - // Create user - UserDTO userDTO = UserDTO.builder() - .username(username) - .email(email) - .prenom(firstName) - .nom(lastName) - .enabled(enabled) - .build(); + // Créer l'utilisateur + String username = fields[0].trim(); + String email = fields[1].trim(); + String prenom = fields.length > 2 ? fields[2].trim() : ""; + String nom = fields.length > 3 ? fields[3].trim() : ""; + String telephone = fields.length > 4 ? fields[4].trim() : null; + Boolean enabled = fields.length > 5 ? Boolean.parseBoolean(fields[5].trim()) : true; + Boolean emailVerified = fields.length > 6 ? Boolean.parseBoolean(fields[6].trim()) : false; - try { - createUser(userDTO, realmName); - result.setSuccessCount(result.getSuccessCount() + 1); - log.debug("✅ Utilisateur créé: {} (ligne {})", username, lineNumber); - } catch (Exception e) { - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.CREATION_ERROR) - .message("Erreur lors de la création de l'utilisateur") - .details(e.getMessage()) - .build()); + // Valider les champs obligatoires + if (username.isBlank() || email.isBlank()) { + errors.add(ImportResultDTO.ImportErrorDTO.builder() + .lineNumber(lineNumber) + .lineContent(line) + .errorType(ImportResultDTO.ErrorType.VALIDATION_ERROR) + .field(username.isBlank() ? "username" : "email") + .message("Le username et l'email sont obligatoires") + .build()); + continue; } + // Vérifier si l'utilisateur existe déjà + if (emailExists(email, realmName)) { + errors.add(ImportResultDTO.ImportErrorDTO.builder() + .lineNumber(lineNumber) + .lineContent(line) + .errorType(ImportResultDTO.ErrorType.DUPLICATE_USER) + .field("email") + .message("Un utilisateur avec cet email existe déjà") + .build()); + continue; + } + + // Créer l'utilisateur + UserDTO newUser = UserDTO.builder() + .username(username) + .email(email) + .prenom(prenom) + .nom(nom) + .telephone(telephone) + .enabled(enabled) + .emailVerified(emailVerified) + .build(); + + createUser(newUser, realmName); + successCount++; + } catch (Exception e) { - log.error("Erreur inattendue lors du traitement de la ligne {}: {}", lineNumber, e.getMessage(), e); - result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder() - .lineNumber(lineNumber) - .lineContent(line) - .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.SYSTEM_ERROR) - .message("Erreur système") - .details(e.getMessage()) - .build()); + log.error("Erreur lors de l'import de la ligne {}: {}", lineNumber, e.getMessage()); + errors.add(ImportResultDTO.ImportErrorDTO.builder() + .lineNumber(lineNumber) + .lineContent(line) + .errorType(ImportResultDTO.ErrorType.CREATION_ERROR) + .message("Erreur lors de la création: " + e.getMessage()) + .details(e.getClass().getSimpleName()) + .build()); } } - // Generate summary message - result.generateMessage(); - log.info(result.getMessage()); + resultBuilder.successCount(successCount); + resultBuilder.errors(errors); + resultBuilder.errorCount(errors.size()); + ImportResultDTO result = resultBuilder.build(); + result.generateMessage(); + + log.info("✅ Import CSV terminé: {} succès, {} erreurs", successCount, errors.size()); return result; } - private String escape(String data) { - if (data == null) - return ""; - String escapedData = data.replaceAll("\"", "\"\""); - if (escapedData.contains(",") || escapedData.contains("\n") || escapedData.contains("\"")) { - return "\"" + escapedData + "\""; - } - return escapedData; - } - private String[] parseCSVLine(String line) { - // Simple regex to split by comma but ignoring commas inside quotes - // This regex handles: "value",value,"val,ue" - String[] tokens = line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); - for (int i = 0; i < tokens.length; i++) { - String token = tokens[i].trim(); - if (token.startsWith("\"") && token.endsWith("\"")) { - token = token.substring(1, token.length() - 1); - token = token.replaceAll("\"\"", "\""); + List fields = new ArrayList<>(); + boolean inQuotes = false; + StringBuilder currentField = new StringBuilder(); + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + + if (c == '"') { + if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') { + // Guillemet échappé + currentField.append('"'); + i++; // Passer le guillemet suivant + } else { + // Toggle inQuotes + inQuotes = !inQuotes; + } + } else if (c == ',' && !inQuotes) { + // Fin du champ + fields.add(currentField.toString()); + currentField = new StringBuilder(); + } else { + currentField.append(c); } - tokens[i] = token; } - return tokens; + + // Ajouter le dernier champ + fields.add(currentField.toString()); + + return fields.toArray(new String[0]); } // ==================== Méthodes privées ==================== - /** - * Valide une réponse HTTP du service Keycloak. - * - * @param response La réponse à valider - * @param operation Nom de l'opération (pour les logs) - * @param expectedStatus Le code de statut HTTP attendu - * @throws KeycloakServiceException si la réponse est null ou a un code d'erreur - */ - private void validateResponse(Response response, String operation, int expectedStatus) { - if (response == null) { - log.error("❌ Réponse null lors de l'opération {} - Service Keycloak indisponible", operation); - throw new KeycloakServiceException.ServiceUnavailableException( - "Impossible de se connecter au service Keycloak pour l'opération: " + operation); - } - - int status = response.getStatus(); - if (status != expectedStatus) { - String errorMessage = "Échec de l'opération: " + operation; - if (response.getStatusInfo() != null) { - errorMessage += " - " + response.getStatusInfo(); - } - - // Gérer les différents codes d'erreur HTTP - if (status == 400) { - throw new KeycloakServiceException("Données invalides: " + errorMessage, status); - } else if (status == 401) { - throw new KeycloakServiceException("Non autorisé: " + errorMessage, status); - } else if (status == 403) { - throw new KeycloakServiceException("Accès interdit: " + errorMessage, status); - } else if (status == 404) { - throw new KeycloakServiceException("Ressource non trouvée: " + errorMessage, status); - } else if (status == 409) { - throw new KeycloakServiceException("Conflit: " + errorMessage, status); - } else if (status == 500) { - throw new KeycloakServiceException("Erreur serveur interne Keycloak: " + errorMessage, status); - } else if (status == 502 || status == 503) { - throw new KeycloakServiceException.ServiceUnavailableException("Service Keycloak indisponible: " + errorMessage); - } else if (status == 504) { - throw new KeycloakServiceException.TimeoutException("Timeout lors de l'opération: " + operation); - } else { - throw new KeycloakServiceException(errorMessage, status); - } - } - } - - /** - * Gère les exceptions de connexion et les convertit en KeycloakServiceException appropriée. - * - * @throws KeycloakServiceException toujours (lève une exception) - */ - private void handleConnectionException(Exception e, String operation) throws KeycloakServiceException { - String errorMessage = e.getMessage(); - - if (e instanceof ConnectException || - e instanceof SocketTimeoutException || - (errorMessage != null && (errorMessage.contains("Connection") || - errorMessage.contains("timeout") || - errorMessage.contains("refused") || - errorMessage.contains("Unable to connect")))) { - log.error("❌ Erreur de connexion au service Keycloak lors de l'opération {}", operation, e); - throw new KeycloakServiceException.ServiceUnavailableException( - "Erreur de connexion au service Keycloak: " + (errorMessage != null ? errorMessage : e.getClass().getSimpleName()), e); - } - - // Pour les autres exceptions, vérifier si c'est une KeycloakServiceException déjà - if (e instanceof KeycloakServiceException) { - throw (KeycloakServiceException) e; - } - - // Sinon, encapsuler dans une KeycloakServiceException générique - throw new KeycloakServiceException("Erreur lors de l'opération " + operation + ": " + - (errorMessage != null ? errorMessage : e.getClass().getSimpleName()), e); - } - - private void setPassword(String userId, String realmName, String password, boolean temporary) throws KeycloakServiceException { + private void setPassword(String userId, String realmName, String password, boolean temporary) { try { UserResource userResource = keycloakAdminClient.getUsers(realmName).get(userId); @@ -896,7 +709,7 @@ public class UserServiceImpl implements UserService { log.info("✅ Mot de passe défini pour l'utilisateur {} (temporaire: {})", userId, temporary); } catch (Exception e) { log.error("❌ Erreur lors de la définition du mot de passe pour {}", userId, e); - handleConnectionException(e, "définition du mot de passe pour l'utilisateur " + userId); + throw new RuntimeException("Impossible de définir le mot de passe", e); } } @@ -914,10 +727,144 @@ public class UserServiceImpl implements UserService { return false; } - // TODO: Ajouter d'autres filtres selon les besoins + // Filtrer par username + if (criteria.getUsername() != null + && !criteria.getUsername().equalsIgnoreCase(user.getUsername())) { + return false; + } + + // Filtrer par email + if (criteria.getEmail() != null && !criteria.getEmail().equalsIgnoreCase(user.getEmail())) { + return false; + } + + // Filtrer par prénom + if (criteria.getPrenom() != null) { + String prenom = user.getFirstName(); + if (prenom == null || !prenom.toLowerCase().contains(criteria.getPrenom().toLowerCase())) { + return false; + } + } + + // Filtrer par nom + if (criteria.getNom() != null) { + String nom = user.getLastName(); + if (nom == null || !nom.toLowerCase().contains(criteria.getNom().toLowerCase())) { + return false; + } + } + + // Filtrer par téléphone + if (criteria.getTelephone() != null) { + String phone = user.getAttributes() != null + ? user.getAttributes().getOrDefault("phone_number", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (phone == null || !phone.contains(criteria.getTelephone())) { + return false; + } + } + + // Filtrer par statut (basé sur enabled et emailVerified) + if (criteria.getStatut() != null) { + StatutUser userStatut = determineUserStatut(user); + if (!criteria.getStatut().equals(userStatut)) { + return false; + } + } + + // Filtrer par organisation + if (criteria.getOrganisation() != null) { + String org = user.getAttributes() != null + ? user.getAttributes().getOrDefault("organization", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (org == null || !org.toLowerCase().contains(criteria.getOrganisation().toLowerCase())) { + return false; + } + } + + // Filtrer par département + if (criteria.getDepartement() != null) { + String dept = user.getAttributes() != null + ? user.getAttributes().getOrDefault("department", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (dept == null || !dept.toLowerCase().contains(criteria.getDepartement().toLowerCase())) { + return false; + } + } + + // Filtrer par fonction + if (criteria.getFonction() != null) { + String fonction = user.getAttributes() != null + ? user.getAttributes().getOrDefault("job_title", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (fonction == null + || !fonction.toLowerCase().contains(criteria.getFonction().toLowerCase())) { + return false; + } + } + + // Filtrer par pays + if (criteria.getPays() != null) { + String pays = user.getAttributes() != null + ? user.getAttributes().getOrDefault("country", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (pays == null || !pays.toLowerCase().contains(criteria.getPays().toLowerCase())) { + return false; + } + } + + // Filtrer par ville + if (criteria.getVille() != null) { + String ville = user.getAttributes() != null + ? user.getAttributes().getOrDefault("city", Collections.emptyList()).stream() + .findFirst().orElse(null) + : null; + if (ville == null || !ville.toLowerCase().contains(criteria.getVille().toLowerCase())) { + return false; + } + } + + // Filtrer par date de création + if (criteria.getDateCreationMin() != null || criteria.getDateCreationMax() != null) { + Long createdTimestamp = user.getCreatedTimestamp(); + if (createdTimestamp != null) { + LocalDateTime createdDate = LocalDateTime.ofEpochSecond( + createdTimestamp / 1000, 0, + java.time.ZoneOffset.UTC); + + if (criteria.getDateCreationMin() != null && + createdDate.isBefore(criteria.getDateCreationMin())) { + return false; + } + + if (criteria.getDateCreationMax() != null && + createdDate.isAfter(criteria.getDateCreationMax())) { + return false; + } + } else { + // Si pas de date de création et qu'un filtre min est défini, exclure + if (criteria.getDateCreationMin() != null) { + return false; + } + } + } return true; }) .collect(Collectors.toList()); } + + private StatutUser determineUserStatut(UserRepresentation user) { + if (!user.isEnabled()) { + return StatutUser.INACTIF; + } + // Si enabled mais email non vérifié, on considère toujours comme ACTIF + // car l'email non vérifié n'empêche pas l'activation du compte + return StatutUser.ACTIF; + } } diff --git a/src/main/resources/META-INF/beans.xml b/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..5e4c686 --- /dev/null +++ b/src/main/resources/META-INF/beans.xml @@ -0,0 +1,6 @@ + + + diff --git a/src/main/resources/META-INF/reflection-config.json b/src/main/resources/META-INF/reflection-config.json new file mode 100644 index 0000000..d64bffe --- /dev/null +++ b/src/main/resources/META-INF/reflection-config.json @@ -0,0 +1,33 @@ +[ + { + "name": "dev.lions.user.manager.dto.realm.RealmAssignmentDTO", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "dev.lions.user.manager.dto.role.RoleDTO", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "dev.lions.user.manager.dto.role.RoleDTO$RoleCompositeDTO", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "dev.lions.user.manager.dto.user.UserDTO", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "dev.lions.user.manager.dto.user.UserSearchResultDTO", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + } +] + diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index b76d83f..d7bc0b9 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,105 +1,101 @@ # ============================================================================ -# Lions User Manager Server - Configuration Développement +# Lions User Manager - Server Implementation Configuration - DEV # ============================================================================ -# Ce fichier contient TOUTES les propriétés spécifiques au développement -# Il surcharge et complète application.properties +# Ce fichier contient UNIQUEMENT les propriétés spécifiques au DÉVELOPPEMENT +# Il surcharge application.properties # ============================================================================ # ============================================ # HTTP Configuration DEV # ============================================ quarkus.http.port=8081 - -# CORS permissif en dev -quarkus.http.cors.origins=* +quarkus.http.host=localhost +quarkus.http.cors.origins=http://localhost:3000,http://localhost:8080,http://localhost:8081,http://localhost:8082 # ============================================ -# Logging DEV (plus verbeux) +# OIDC Configuration DEV # ============================================ -quarkus.log.level=DEBUG -quarkus.log.category."dev.lions.user.manager".level=TRACE -quarkus.log.category."org.keycloak".level=DEBUG -quarkus.log.category."io.quarkus.oidc".level=DEBUG -quarkus.log.category."io.quarkus.oidc.runtime".level=DEBUG -quarkus.log.category."io.quarkus.security".level=DEBUG - -quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n - -# File Logging pour Audit (DEV) -quarkus.log.file.path=logs/dev/lions-user-manager.log -quarkus.log.file.rotation.max-backup-index=3 - -# ============================================ -# OIDC Configuration DEV - DÉSACTIVÉ PAR DÉFAUT -# ============================================ -# En mode DEV, on désactive OIDC sur le backend pour simplifier le développement -# Le client JSF est sécurisé, mais le backend accepte toutes les requêtes -# ATTENTION: NE JAMAIS utiliser cette config en production ! -quarkus.oidc.enabled=false - -# Alternative: Si vous voulez activer OIDC en dev (pour tester le flow complet), -# commentez la ligne "quarkus.oidc.enabled=false" ci-dessus et décommentez ci-dessous: -# -# quarkus.oidc.enabled=true -# quarkus.oidc.auth-server-url=http://localhost:8180/realms/lions-user-manager -# quarkus.oidc.tls.verification=none -# quarkus.oidc.token.issuer=http://localhost:8180/realms/lions-user-manager -# quarkus.oidc.discovery-enabled=true -# quarkus.oidc.token.audience=account -# quarkus.oidc.verify-access-token=true -# quarkus.oidc.roles.role-claim-path=realm_access/roles -# quarkus.security.auth.enabled=true +quarkus.oidc.enabled=true +quarkus.oidc.auth-server-url=http://localhost:8180/realms/lions-user-manager +quarkus.oidc.token.issuer=http://localhost:8180/realms/lions-user-manager +quarkus.oidc.tls.verification=none # ============================================ # Keycloak Admin Client Configuration DEV # ============================================ -# Configuration pour accéder à l'API Admin de Keycloak local -# IMPORTANT: L'utilisateur admin se trouve dans le realm "master", pas "lions-user-manager" lions.keycloak.server-url=http://localhost:8180 -lions.keycloak.admin-realm=master -lions.keycloak.admin-client-id=admin-cli lions.keycloak.admin-username=admin lions.keycloak.admin-password=admin +lions.keycloak.connection-pool-size=5 +lions.keycloak.timeout-seconds=30 +lions.keycloak.authorized-realms=lions-user-manager,master,btpxpress,test-realm -# Timeout augmenté pour Keycloak local (peut être lent au démarrage) -lions.keycloak.timeout-seconds=60 - -# Realms autorisés en dev -lions.keycloak.authorized-realms=lions-user-manager,btpxpress,master,unionflow +# Quarkus-managed Keycloak Admin Client DEV +quarkus.keycloak.admin-client.server-url=${lions.keycloak.server-url} +quarkus.keycloak.admin-client.username=${lions.keycloak.admin-username} +quarkus.keycloak.admin-client.password=${lions.keycloak.admin-password} # ============================================ # Audit Configuration DEV # ============================================ +lions.audit.log-to-database=false +lions.audit.log-to-file=true lions.audit.retention-days=30 +# ============================================ +# Database Configuration DEV +# ============================================ +quarkus.datasource.health.enabled=false +quarkus.datasource.username=${DB_USERNAME:skyfile} +quarkus.datasource.password=${DB_PASSWORD:skyfile} +quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:lions_user_manager_dev} + +# ============================================ +# Hibernate ORM Configuration DEV +# ============================================ +quarkus.hibernate-orm.database.generation=update +quarkus.hibernate-orm.log.sql=true + +# ============================================ +# Flyway Configuration DEV +# ============================================ +quarkus.flyway.migrate-at-start=false + +# ============================================ +# Logging Configuration DEV +# ============================================ +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=DEBUG +quarkus.log.category."dev.lions.user.manager.security".level=DEBUG +quarkus.log.category."org.keycloak".level=INFO +quarkus.log.category."io.quarkus".level=INFO +quarkus.log.category."io.quarkus.oidc".level=INFO +quarkus.log.category."io.quarkus.oidc.runtime".level=DEBUG +quarkus.log.category."io.quarkus.security".level=DEBUG +quarkus.log.category."io.quarkus.security.runtime".level=DEBUG + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n + +# File Logging pour Audit DEV +quarkus.log.file.enable=true +quarkus.log.file.path=logs/dev/lions-user-manager.log +quarkus.log.file.rotation.max-file-size=10M +quarkus.log.file.rotation.max-backup-index=3 + # ============================================ # OpenAPI/Swagger Configuration DEV # ============================================ +quarkus.swagger-ui.always-include=true quarkus.swagger-ui.enable=true # ============================================ -# Security Configuration DEV +# Dev Services DEV # ============================================ -# Security désactivée en dev (car OIDC est désactivé) -quarkus.security.auth.enabled=false -quarkus.security.jaxrs.deny-unannotated-endpoints=false -quarkus.security.auth.proactive=false - -# Permissions HTTP - Accès public à tous les endpoints en DEV -quarkus.http.auth.permission.public.paths=/api/*,/q/*,/health/*,/metrics,/swagger-ui/*,/openapi -quarkus.http.auth.permission.public.policy=permit +quarkus.devservices.enabled=false # ============================================ -# Hot Reload et Dev Mode +# Hot Reload DEV # ============================================ quarkus.live-reload.instrumentation=true quarkus.test.continuous-testing=disabled -quarkus.profile=dev - -# ============================================ -# Indexation des dépendances Keycloak -# ============================================ -quarkus.index-dependency.keycloak-admin.group-id=org.keycloak -quarkus.index-dependency.keycloak-admin.artifact-id=keycloak-admin-client -quarkus.index-dependency.keycloak-core.group-id=org.keycloak -quarkus.index-dependency.keycloak-core.artifact-id=keycloak-core diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 881a6af..3174f23 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,66 +1,38 @@ # ============================================================================ -# Lions User Manager Server - Configuration Production +# Lions User Manager - Server Implementation Configuration - PROD # ============================================================================ -# Ce fichier contient TOUTES les propriétés spécifiques à la production -# Il surcharge et complète application.properties +# Ce fichier contient UNIQUEMENT les propriétés spécifiques à la PRODUCTION +# Il surcharge application.properties # ============================================================================ # ============================================ # HTTP Configuration PROD # ============================================ quarkus.http.port=8080 - -# CORS restrictif en production (via variable d'environnement) -quarkus.http.cors.origins=${CORS_ORIGINS:https://btpxpress.lions.dev,https://admin.lions.dev} +quarkus.http.cors.origins=${CORS_ORIGINS:https://users.lions.dev,https://btpxpress.lions.dev,https://admin.lions.dev} # ============================================ -# Logging PROD (moins verbeux) -# ============================================ -quarkus.log.level=INFO -quarkus.log.category."dev.lions.user.manager".level=INFO -quarkus.log.category."org.keycloak".level=WARN - -quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n - -# File Logging pour Audit (PROD) -quarkus.log.file.path=/var/log/lions/lions-user-manager.log -quarkus.log.file.rotation.max-file-size=50M -quarkus.log.file.rotation.max-backup-index=30 -quarkus.log.file.rotation.rotate-on-boot=false - -# ============================================ -# OIDC Configuration PROD - OBLIGATOIRE ET ACTIF +# OIDC Configuration PROD # ============================================ quarkus.oidc.enabled=true -quarkus.oidc.auth-server-url=${KEYCLOAK_AUTH_SERVER_URL:https://security.lions.dev/realms/master} -quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET} -quarkus.oidc.token.issuer=${KEYCLOAK_AUTH_SERVER_URL:https://security.lions.dev/realms/master} - -# Vérification TLS requise en production +quarkus.oidc.auth-server-url=${KEYCLOAK_AUTH_SERVER_URL:https://security.lions.dev/realms/lions-user-manager} +quarkus.oidc.token.issuer=${KEYCLOAK_AUTH_SERVER_URL:https://security.lions.dev/realms/lions-user-manager} quarkus.oidc.tls.verification=required -# Vérification stricte des tokens -quarkus.oidc.discovery-enabled=true -quarkus.oidc.verify-access-token=true - -# Extraction des rôles -quarkus.oidc.roles.role-claim-path=realm_access/roles - # ============================================ # Keycloak Admin Client Configuration PROD # ============================================ lions.keycloak.server-url=${KEYCLOAK_SERVER_URL:https://security.lions.dev} -lions.keycloak.admin-realm=${KEYCLOAK_ADMIN_REALM:master} -lions.keycloak.admin-client-id=admin-cli lions.keycloak.admin-username=${KEYCLOAK_ADMIN_USERNAME} lions.keycloak.admin-password=${KEYCLOAK_ADMIN_PASSWORD} - -# Pool de connexions augmenté en production lions.keycloak.connection-pool-size=20 lions.keycloak.timeout-seconds=60 +lions.keycloak.authorized-realms=${KEYCLOAK_AUTHORIZED_REALMS:lions-user-manager,btpxpress,master,unionflow} -# Realms autorisés en production (via variable d'environnement) -lions.keycloak.authorized-realms=${KEYCLOAK_AUTHORIZED_REALMS:btpxpress,master,unionflow} +# Quarkus-managed Keycloak Admin Client PROD +quarkus.keycloak.admin-client.server-url=${lions.keycloak.server-url} +quarkus.keycloak.admin-client.username=${lions.keycloak.admin-username} +quarkus.keycloak.admin-client.password=${lions.keycloak.admin-password} # ============================================ # Retry Configuration PROD @@ -71,40 +43,51 @@ lions.keycloak.retry.delay-seconds=3 # ============================================ # Audit Configuration PROD # ============================================ -lions.audit.retention-days=365 lions.audit.log-to-database=true +lions.audit.log-to-file=false +lions.audit.retention-days=365 # ============================================ -# Database Configuration PROD (pour audit) +# Database Configuration PROD # ============================================ -quarkus.datasource.db-kind=postgresql -quarkus.datasource.username=${DB_USERNAME:audit_user} +quarkus.datasource.health.enabled=true +quarkus.datasource.username=${DB_USERNAME} quarkus.datasource.password=${DB_PASSWORD} -quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST:lions-db.lions.svc.cluster.local}:${DB_PORT:5432}/${DB_NAME:lions_audit} -quarkus.datasource.jdbc.max-size=20 -quarkus.datasource.jdbc.min-size=5 -quarkus.hibernate-orm.enabled=true +quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:lions_user_manager} + +# ============================================ +# Hibernate ORM Configuration PROD +# ============================================ quarkus.hibernate-orm.database.generation=none +quarkus.hibernate-orm.log.sql=false + +# ============================================ +# Flyway Configuration PROD +# ============================================ quarkus.flyway.migrate-at-start=true -quarkus.flyway.baseline-on-migrate=true -quarkus.flyway.baseline-version=1.0.0 + +# ============================================ +# Logging Configuration PROD +# ============================================ +quarkus.log.level=INFO +quarkus.log.category."dev.lions.user.manager".level=INFO +quarkus.log.category."org.keycloak".level=WARN +quarkus.log.category."io.quarkus".level=INFO + +quarkus.log.console.enable=true +quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n + +# File Logging désactivé en PROD (logs centralisés via Kubernetes) +quarkus.log.file.enable=false # ============================================ # OpenAPI/Swagger Configuration PROD # ============================================ -# Swagger désactivé en production par défaut quarkus.swagger-ui.always-include=false quarkus.swagger-ui.enable=false # ============================================ -# Security Configuration PROD (strict) -# ============================================ -quarkus.security.auth.enabled=true -quarkus.security.jaxrs.deny-unannotated-endpoints=true -quarkus.security.auth.proactive=true - -# ============================================ -# Performance tuning PROD +# Performance Tuning PROD # ============================================ quarkus.thread-pool.core-threads=4 quarkus.thread-pool.max-threads=32 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ac96c9..c1b5f41 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,20 +1,18 @@ # ============================================================================ -# Lions User Manager Server - Configuration Commune +# Lions User Manager - Server Implementation Configuration (COMMUNE) # ============================================================================ -# Ce fichier contient UNIQUEMENT la configuration commune à tous les environnements -# Les configurations spécifiques sont dans: -# - application-dev.properties (développement) -# - application-prod.properties (production) +# Ce fichier contient UNIQUEMENT les propriétés COMMUNES à tous les environnements +# Les propriétés spécifiques dev/prod vont dans application-dev.properties et application-prod.properties # ============================================================================ # ============================================ -# Application Info +# Application Info (COMMUNE) # ============================================ quarkus.application.name=lions-user-manager-server quarkus.application.version=1.0.0 # ============================================ -# HTTP Configuration (commune) +# HTTP Configuration (COMMUNE) # ============================================ quarkus.http.host=0.0.0.0 quarkus.http.cors=true @@ -22,54 +20,49 @@ quarkus.http.cors.methods=GET,POST,PUT,DELETE,PATCH,OPTIONS quarkus.http.cors.headers=* # ============================================ -# Keycloak OIDC Configuration (base commune) +# OIDC Configuration (COMMUNE) # ============================================ quarkus.oidc.application-type=service +quarkus.oidc.discovery-enabled=true +quarkus.oidc.roles.role-claim-path=realm_access/roles +quarkus.oidc.token.audience=account # ============================================ -# Keycloak Admin Client Configuration (base commune) +# Keycloak Admin Client (COMMUNE) # ============================================ -lions.keycloak.connection-pool-size=10 -lions.keycloak.timeout-seconds=30 +lions.keycloak.admin-realm=master +lions.keycloak.admin-client-id=admin-cli -# Retry Configuration (pour appels Keycloak) +# Quarkus-managed Keycloak Admin Client (uses Quarkus ObjectMapper with fail-on-unknown-properties=false) +quarkus.keycloak.admin-client.realm=${lions.keycloak.admin-realm} +quarkus.keycloak.admin-client.client-id=${lions.keycloak.admin-client-id} +quarkus.keycloak.admin-client.grant-type=PASSWORD + +# ============================================ +# Retry Configuration (COMMUNE) +# ============================================ lions.keycloak.retry.max-attempts=3 lions.keycloak.retry.delay-seconds=2 # ============================================ -# Audit Configuration +# Audit Configuration (COMMUNE) # ============================================ lions.audit.enabled=true -lions.audit.log-to-database=false -lions.audit.log-to-file=true -lions.audit.retention-days=90 # ============================================ -# Database Configuration (désactivé par défaut) +# Database Configuration (COMMUNE) # ============================================ -# Désactiver Hibernate ORM si aucune entité JPA n'est utilisée -quarkus.hibernate-orm.enabled=false +quarkus.datasource.db-kind=postgresql +quarkus.datasource.devservices.enabled=false # ============================================ -# Logging Configuration (base commune) +# Flyway Configuration (COMMUNE) # ============================================ -quarkus.log.level=INFO -quarkus.log.category."dev.lions.user.manager".level=DEBUG -quarkus.log.category."org.keycloak".level=WARN - -quarkus.log.console.enable=true -quarkus.log.console.format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n - -# File Logging pour Audit -quarkus.log.file.enable=true -quarkus.log.file.path=logs/lions-user-manager.log -quarkus.log.file.rotation.max-file-size=10M -quarkus.log.file.rotation.max-backup-index=10 +# Migration manuelle en production, vérifier avant d'activer # ============================================ -# OpenAPI/Swagger Configuration +# OpenAPI/Swagger Configuration (COMMUNE) # ============================================ -quarkus.swagger-ui.always-include=true mp.openapi.extensions.smallrye.info.title=Lions User Manager API mp.openapi.extensions.smallrye.info.version=1.0.0 mp.openapi.extensions.smallrye.info.description=API de gestion centralisée des utilisateurs Keycloak @@ -77,32 +70,35 @@ mp.openapi.extensions.smallrye.info.contact.name=Lions Dev Team mp.openapi.extensions.smallrye.info.contact.email=contact@lions.dev # ============================================ -# Health Check Configuration +# Health Check Configuration (COMMUNE) # ============================================ quarkus.smallrye-health.root-path=/health quarkus.smallrye-health.liveness-path=/health/live quarkus.smallrye-health.readiness-path=/health/ready # ============================================ -# Metrics Configuration +# Metrics Configuration (COMMUNE) # ============================================ quarkus.micrometer.enabled=true quarkus.micrometer.export.prometheus.enabled=true quarkus.micrometer.export.prometheus.path=/metrics # ============================================ -# Security Configuration +# Security Configuration (COMMUNE) # ============================================ quarkus.security.jaxrs.deny-unannotated-endpoints=false # ============================================ -# Jackson Configuration +# Jackson Configuration (COMMUNE) # ============================================ quarkus.jackson.fail-on-unknown-properties=false quarkus.jackson.write-dates-as-timestamps=false quarkus.jackson.serialization-inclusion=non_null # ============================================ -# Dev Services (désactivé par défaut) +# Indexing (COMMUNE - pour Keycloak) # ============================================ -quarkus.devservices.enabled=false +quarkus.index-dependency.keycloak-admin.group-id=org.keycloak +quarkus.index-dependency.keycloak-admin.artifact-id=keycloak-admin-client +quarkus.index-dependency.keycloak-core.group-id=org.keycloak +quarkus.index-dependency.keycloak-core.artifact-id=keycloak-core diff --git a/src/main/resources/db/migration/V2.0.0__Create_sync_tables.sql b/src/main/resources/db/migration/V2.0.0__Create_sync_tables.sql new file mode 100644 index 0000000..c234d62 --- /dev/null +++ b/src/main/resources/db/migration/V2.0.0__Create_sync_tables.sql @@ -0,0 +1,85 @@ +-- ============================================================================= +-- Migration Flyway V2.0.0 - Création des tables de synchronisation Keycloak +-- ============================================================================= +-- Description: Tables pour la persistance des snapshots et de l'historique +-- des synchronisations entre l'application et Keycloak. +-- +-- Entités correspondantes: +-- SyncHistoryEntity → sync_history +-- SyncedUserEntity → synced_user +-- SyncedRoleEntity → synced_role +-- +-- Auteur: Lions Development Team +-- Date: 2026-02-17 +-- Version: 2.0.0 +-- ============================================================================= + + +-- ============================================================================= +-- TABLE sync_history : historique des opérations de synchronisation +-- ============================================================================= +CREATE TABLE IF NOT EXISTS sync_history ( + id BIGSERIAL PRIMARY KEY, + realm_name VARCHAR(255) NOT NULL, + sync_date TIMESTAMP NOT NULL, + sync_type VARCHAR(50) NOT NULL, -- 'USER' ou 'ROLE' + status VARCHAR(50) NOT NULL, -- 'SUCCESS' ou 'FAILURE' + items_processed INTEGER, + duration_ms BIGINT, + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sync_realm ON sync_history(realm_name); +CREATE INDEX IF NOT EXISTS idx_sync_date ON sync_history(sync_date DESC); + +COMMENT ON TABLE sync_history IS 'Historique des synchronisations Keycloak (users et rôles)'; +COMMENT ON COLUMN sync_history.sync_type IS 'Type de synchronisation : USER ou ROLE'; +COMMENT ON COLUMN sync_history.status IS 'Résultat : SUCCESS ou FAILURE'; + + +-- ============================================================================= +-- TABLE synced_user : snapshot local des utilisateurs Keycloak synchronisés +-- ============================================================================= +CREATE TABLE IF NOT EXISTS synced_user ( + id BIGSERIAL PRIMARY KEY, + realm_name VARCHAR(255) NOT NULL, + keycloak_id VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + email VARCHAR(255), + enabled BOOLEAN, + email_verified BOOLEAN, + created_at TIMESTAMP, + CONSTRAINT uq_synced_user_realm_kc UNIQUE (realm_name, keycloak_id) +); + +CREATE INDEX IF NOT EXISTS idx_synced_user_realm + ON synced_user(realm_name); +CREATE UNIQUE INDEX IF NOT EXISTS idx_synced_user_realm_kc_id + ON synced_user(realm_name, keycloak_id); + +COMMENT ON TABLE synced_user IS 'Snapshot local des utilisateurs Keycloak pour rapports et vérifications'; +COMMENT ON COLUMN synced_user.keycloak_id IS 'UUID Keycloak de l''utilisateur'; + + +-- ============================================================================= +-- TABLE synced_role : snapshot local des rôles Keycloak synchronisés +-- ============================================================================= +CREATE TABLE IF NOT EXISTS synced_role ( + id BIGSERIAL PRIMARY KEY, + realm_name VARCHAR(255) NOT NULL, + role_name VARCHAR(255) NOT NULL, + description VARCHAR(500), + CONSTRAINT uq_synced_role_realm_name UNIQUE (realm_name, role_name) +); + +CREATE INDEX IF NOT EXISTS idx_synced_role_realm + ON synced_role(realm_name); +CREATE UNIQUE INDEX IF NOT EXISTS idx_synced_role_realm_name + ON synced_role(realm_name, role_name); + +COMMENT ON TABLE synced_role IS 'Snapshot local des rôles Keycloak pour rapports et vérifications'; +COMMENT ON COLUMN synced_role.role_name IS 'Nom du rôle realm dans Keycloak'; + +-- ============================================================================= +-- FIN DE LA MIGRATION +-- ============================================================================= diff --git a/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplCompleteTest.java b/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplCompleteTest.java index ee225de..7bcee39 100644 --- a/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplCompleteTest.java +++ b/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplCompleteTest.java @@ -1,37 +1,35 @@ package dev.lions.user.manager.client; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.ClientBuilder; -import jakarta.ws.rs.client.Invocation; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.admin.client.resource.UsersResource; import org.keycloak.admin.client.token.TokenManager; +import org.keycloak.representations.info.ServerInfoRepresentation; +import org.keycloak.admin.client.resource.ServerInfoResource; import org.mockito.InjectMocks; -import org.mockito.MockedStatic; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import jakarta.ws.rs.NotFoundException; import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** - * Tests complets pour KeycloakAdminClientImpl pour atteindre 100% de couverture - * Couvre init(), getAllRealms(), reconnect(), et tous les cas limites + * Tests complets pour KeycloakAdminClientImpl */ @ExtendWith(MockitoExtension.class) class KeycloakAdminClientImplCompleteTest { + @Mock + Keycloak mockKeycloak; + @InjectMocks KeycloakAdminClientImpl client; @@ -43,315 +41,123 @@ class KeycloakAdminClientImplCompleteTest { @BeforeEach void setUp() throws Exception { - // Set all config fields to null/empty for testing - setField("serverUrl", ""); + setField("serverUrl", "http://localhost:8180"); setField("adminRealm", "master"); setField("adminClientId", "admin-cli"); setField("adminUsername", "admin"); - setField("adminPassword", ""); - setField("connectionPoolSize", 10); - setField("timeoutSeconds", 30); - setField("keycloak", null); } @Test - void testInit_WithServerUrl() throws Exception { - setField("serverUrl", "http://localhost:8080"); - setField("adminRealm", "master"); - setField("adminClientId", "admin-cli"); - setField("adminUsername", "admin"); - setField("adminPassword", "password"); - - // Mock KeycloakBuilder to avoid actual connection - // This will likely throw an exception, but that's ok - we test the exception path - try { - java.lang.reflect.Method initMethod = KeycloakAdminClientImpl.class.getDeclaredMethod("init"); - initMethod.setAccessible(true); - initMethod.invoke(client); - } catch (Exception e) { - // Expected - KeycloakBuilder will fail without actual Keycloak server - } - - // The init method will set keycloak to null on exception - Field keycloakField = KeycloakAdminClientImpl.class.getDeclaredField("keycloak"); - keycloakField.setAccessible(true); - Keycloak result = (Keycloak) keycloakField.get(client); - // Result will be null due to exception, which is the expected behavior - // This test covers the exception path in init() + void testGetInstance() { + Keycloak result = client.getInstance(); + assertSame(mockKeycloak, result); } @Test - void testInit_WithException() throws Exception { - setField("serverUrl", "http://localhost:8080"); - setField("adminRealm", "master"); - setField("adminClientId", "admin-cli"); - setField("adminUsername", "admin"); - setField("adminPassword", "password"); + void testGetRealm_Success() { + RealmResource mockRealmResource = mock(RealmResource.class); + when(mockKeycloak.realm("test-realm")).thenReturn(mockRealmResource); - // Call init via reflection - will throw exception without actual Keycloak - // This test covers the exception handling path in init() - try { - java.lang.reflect.Method initMethod = KeycloakAdminClientImpl.class.getDeclaredMethod("init"); - initMethod.setAccessible(true); - initMethod.invoke(client); - } catch (Exception e) { - // Expected - KeycloakBuilder may fail - } - - // Verify keycloak is null after exception - Field keycloakField = KeycloakAdminClientImpl.class.getDeclaredField("keycloak"); - keycloakField.setAccessible(true); - Keycloak result = (Keycloak) keycloakField.get(client); - // Result may be null due to exception, which is the expected behavior - // This test covers the exception handling path in init() + RealmResource result = client.getRealm("test-realm"); + assertSame(mockRealmResource, result); } @Test - void testInit_WithNullServerUrl() throws Exception { - setField("serverUrl", null); + void testGetRealm_Exception() { + when(mockKeycloak.realm("bad-realm")).thenThrow(new RuntimeException("Connection error")); - // Call init via reflection - java.lang.reflect.Method initMethod = KeycloakAdminClientImpl.class.getDeclaredMethod("init"); - initMethod.setAccessible(true); - initMethod.invoke(client); - - // Verify keycloak is null - Field keycloakField = KeycloakAdminClientImpl.class.getDeclaredField("keycloak"); - keycloakField.setAccessible(true); - Keycloak result = (Keycloak) keycloakField.get(client); - assertNull(result); + assertThrows(RuntimeException.class, () -> client.getRealm("bad-realm")); } @Test - void testInit_WithEmptyServerUrl() throws Exception { - setField("serverUrl", ""); + void testGetUsers() { + RealmResource mockRealmResource = mock(RealmResource.class); + UsersResource mockUsersResource = mock(UsersResource.class); + when(mockKeycloak.realm("test-realm")).thenReturn(mockRealmResource); + when(mockRealmResource.users()).thenReturn(mockUsersResource); - // Call init via reflection - java.lang.reflect.Method initMethod = KeycloakAdminClientImpl.class.getDeclaredMethod("init"); - initMethod.setAccessible(true); - initMethod.invoke(client); - - // Verify keycloak is null - Field keycloakField = KeycloakAdminClientImpl.class.getDeclaredField("keycloak"); - keycloakField.setAccessible(true); - Keycloak result = (Keycloak) keycloakField.get(client); - assertNull(result); + UsersResource result = client.getUsers("test-realm"); + assertSame(mockUsersResource, result); } @Test - void testReconnect() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", ""); + void testGetRoles() { + RealmResource mockRealmResource = mock(RealmResource.class); + RolesResource mockRolesResource = mock(RolesResource.class); + when(mockKeycloak.realm("test-realm")).thenReturn(mockRealmResource); + when(mockRealmResource.roles()).thenReturn(mockRolesResource); - // reconnect calls close() then init() - client.reconnect(); - - // Verify close was called - verify(mockKeycloak).close(); - - // Verify keycloak is null after close - Field keycloakField = KeycloakAdminClientImpl.class.getDeclaredField("keycloak"); - keycloakField.setAccessible(true); - Keycloak result = (Keycloak) keycloakField.get(client); - assertNull(result); + RolesResource result = client.getRoles("test-realm"); + assertSame(mockRolesResource, result); } @Test - void testGetAllRealms_Success() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testIsConnected_True() { + ServerInfoResource mockServerInfoResource = mock(ServerInfoResource.class); + when(mockKeycloak.serverInfo()).thenReturn(mockServerInfoResource); + when(mockServerInfoResource.getInfo()).thenReturn(mock(ServerInfoRepresentation.class)); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenReturn("test-token"); - - // Mock ClientBuilder - try (MockedStatic mockedClientBuilder = mockStatic(ClientBuilder.class)) { - Client mockClient = mock(Client.class); - WebTarget mockWebTarget = mock(WebTarget.class); - Invocation.Builder mockBuilder = mock(Invocation.Builder.class); - Response mockResponse = mock(Response.class); - - mockedClientBuilder.when(ClientBuilder::newClient).thenReturn(mockClient); - when(mockClient.target("http://localhost:8080/admin/realms")).thenReturn(mockWebTarget); - when(mockWebTarget.request(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)).thenReturn(mockBuilder); - when(mockBuilder.header(anyString(), anyString())).thenReturn(mockBuilder); - - // Mock response with realm data - Map realm1 = new HashMap<>(); - realm1.put("realm", "realm1"); - Map realm2 = new HashMap<>(); - realm2.put("realm", "realm2"); - List> realmsJson = new ArrayList<>(); - realmsJson.add(realm1); - realmsJson.add(realm2); - - when(mockBuilder.get(List.class)).thenReturn(realmsJson); - - List result = client.getAllRealms(); - - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.contains("realm1")); - assertTrue(result.contains("realm2")); - verify(mockClient).close(); - } + assertTrue(client.isConnected()); } @Test - void testGetAllRealms_WithNullRealmsJson() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testIsConnected_False() { + when(mockKeycloak.serverInfo()).thenThrow(new RuntimeException("Connection refused")); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenReturn("test-token"); - - try (MockedStatic mockedClientBuilder = mockStatic(ClientBuilder.class)) { - Client mockClient = mock(Client.class); - WebTarget mockWebTarget = mock(WebTarget.class); - Invocation.Builder mockBuilder = mock(Invocation.Builder.class); - - mockedClientBuilder.when(ClientBuilder::newClient).thenReturn(mockClient); - when(mockClient.target("http://localhost:8080/admin/realms")).thenReturn(mockWebTarget); - when(mockWebTarget.request(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)).thenReturn(mockBuilder); - when(mockBuilder.header(anyString(), anyString())).thenReturn(mockBuilder); - when(mockBuilder.get(List.class)).thenReturn(null); - - List result = client.getAllRealms(); - - assertNotNull(result); - assertTrue(result.isEmpty()); - verify(mockClient).close(); - } + assertFalse(client.isConnected()); } @Test - void testGetAllRealms_WithEmptyRealmName() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testRealmExists_True() { + RealmResource mockRealmResource = mock(RealmResource.class); + RolesResource mockRolesResource = mock(RolesResource.class); + when(mockKeycloak.realm("test-realm")).thenReturn(mockRealmResource); + when(mockRealmResource.roles()).thenReturn(mockRolesResource); + when(mockRolesResource.list()).thenReturn(List.of()); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenReturn("test-token"); - - try (MockedStatic mockedClientBuilder = mockStatic(ClientBuilder.class)) { - Client mockClient = mock(Client.class); - WebTarget mockWebTarget = mock(WebTarget.class); - Invocation.Builder mockBuilder = mock(Invocation.Builder.class); - - mockedClientBuilder.when(ClientBuilder::newClient).thenReturn(mockClient); - when(mockClient.target("http://localhost:8080/admin/realms")).thenReturn(mockWebTarget); - when(mockWebTarget.request(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)).thenReturn(mockBuilder); - when(mockBuilder.header(anyString(), anyString())).thenReturn(mockBuilder); - - // Mock response with empty realm name - Map realm1 = new HashMap<>(); - realm1.put("realm", ""); - Map realm2 = new HashMap<>(); - realm2.put("realm", "realm2"); - List> realmsJson = new ArrayList<>(); - realmsJson.add(realm1); - realmsJson.add(realm2); - - when(mockBuilder.get(List.class)).thenReturn(realmsJson); - - List result = client.getAllRealms(); - - assertNotNull(result); - assertEquals(1, result.size()); // Empty realm name should be filtered out - assertTrue(result.contains("realm2")); - verify(mockClient).close(); - } + assertTrue(client.realmExists("test-realm")); } @Test - void testGetAllRealms_WithNullRealmName() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testRealmExists_NotFound() { + RealmResource mockRealmResource = mock(RealmResource.class); + RolesResource mockRolesResource = mock(RolesResource.class); + when(mockKeycloak.realm("missing")).thenReturn(mockRealmResource); + when(mockRealmResource.roles()).thenReturn(mockRolesResource); + when(mockRolesResource.list()).thenThrow(new NotFoundException("Not found")); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenReturn("test-token"); - - try (MockedStatic mockedClientBuilder = mockStatic(ClientBuilder.class)) { - Client mockClient = mock(Client.class); - WebTarget mockWebTarget = mock(WebTarget.class); - Invocation.Builder mockBuilder = mock(Invocation.Builder.class); - - mockedClientBuilder.when(ClientBuilder::newClient).thenReturn(mockClient); - when(mockClient.target("http://localhost:8080/admin/realms")).thenReturn(mockWebTarget); - when(mockWebTarget.request(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)).thenReturn(mockBuilder); - when(mockBuilder.header(anyString(), anyString())).thenReturn(mockBuilder); - - // Mock response with null realm name - Map realm1 = new HashMap<>(); - realm1.put("realm", null); - Map realm2 = new HashMap<>(); - realm2.put("realm", "realm2"); - List> realmsJson = new ArrayList<>(); - realmsJson.add(realm1); - realmsJson.add(realm2); - - when(mockBuilder.get(List.class)).thenReturn(realmsJson); - - List result = client.getAllRealms(); - - assertNotNull(result); - assertEquals(1, result.size()); // Null realm name should be filtered out - assertTrue(result.contains("realm2")); - verify(mockClient).close(); - } + assertFalse(client.realmExists("missing")); } @Test - void testGetAllRealms_WithException() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testRealmExists_OtherException() { + when(mockKeycloak.realm("error-realm")).thenThrow(new RuntimeException("Other error")); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenThrow(new RuntimeException("Error")); - - List result = client.getAllRealms(); - - assertNotNull(result); - assertTrue(result.isEmpty()); // Should return empty list on exception + assertTrue(client.realmExists("error-realm")); } @Test - void testGetAllRealms_WithExceptionInClient() throws Exception { - Keycloak mockKeycloak = mock(Keycloak.class); - TokenManager mockTokenManager = mock(TokenManager.class); - setField("keycloak", mockKeycloak); - setField("serverUrl", "http://localhost:8080"); + void testGetAllRealms_TokenError() { + // When token retrieval fails, getAllRealms should throw + when(mockKeycloak.tokenManager()).thenThrow(new RuntimeException("Token error")); - when(mockKeycloak.tokenManager()).thenReturn(mockTokenManager); - when(mockTokenManager.getAccessTokenString()).thenReturn("test-token"); + assertThrows(RuntimeException.class, () -> client.getAllRealms()); + } - try (MockedStatic mockedClientBuilder = mockStatic(ClientBuilder.class)) { - Client mockClient = mock(Client.class); - WebTarget mockWebTarget = mock(WebTarget.class); - Invocation.Builder mockBuilder = mock(Invocation.Builder.class); + @Test + void testGetAllRealms_NullTokenManager() { + when(mockKeycloak.tokenManager()).thenReturn(null); - mockedClientBuilder.when(ClientBuilder::newClient).thenReturn(mockClient); - when(mockClient.target("http://localhost:8080/admin/realms")).thenReturn(mockWebTarget); - when(mockWebTarget.request(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)).thenReturn(mockBuilder); - when(mockBuilder.header(anyString(), anyString())).thenReturn(mockBuilder); - when(mockBuilder.get(List.class)).thenThrow(new RuntimeException("Connection error")); + assertThrows(RuntimeException.class, () -> client.getAllRealms()); + } - List result = client.getAllRealms(); + @Test + void testClose() { + assertDoesNotThrow(() -> client.close()); + } - assertNotNull(result); - assertTrue(result.isEmpty()); // Should return empty list on exception - verify(mockClient).close(); // Should still close client in finally block - } + @Test + void testReconnect() { + assertDoesNotThrow(() -> client.reconnect()); } } - diff --git a/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplTest.java b/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplTest.java index 8963b94..4e559e6 100644 --- a/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplTest.java +++ b/src/test/java/dev/lions/user/manager/client/KeycloakAdminClientImplTest.java @@ -40,18 +40,20 @@ class KeycloakAdminClientImplTest { @Mock ServerInfoResource serverInfoResource; - @BeforeEach - void setUp() throws Exception { - // Inject the mock keycloak instance - setField(client, "keycloak", keycloak); - } - private void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(target, value); } + @BeforeEach + void setUp() throws Exception { + setField(client, "serverUrl", "http://localhost:8180"); + setField(client, "adminRealm", "master"); + setField(client, "adminClientId", "admin-cli"); + setField(client, "adminUsername", "admin"); + } + @Test void testGetInstance() { Keycloak result = client.getInstance(); @@ -59,18 +61,6 @@ class KeycloakAdminClientImplTest { assertEquals(keycloak, result); } - @Test - void testGetInstanceReInitWhenNull() throws Exception { - // Set keycloak to null - setField(client, "keycloak", null); - - // Should attempt to reinitialize (will fail without config, but that's ok) - // The method should return null since init() will fail without proper config - Keycloak result = client.getInstance(); - // Since config values are null, keycloak will still be null - assertNull(result); - } - @Test void testGetRealm() { when(keycloak.realm("test-realm")).thenReturn(realmResource); @@ -125,13 +115,6 @@ class KeycloakAdminClientImplTest { assertFalse(client.isConnected()); } - @Test - void testIsConnected_false_null() throws Exception { - setField(client, "keycloak", null); - - assertFalse(client.isConnected()); - } - @Test void testRealmExists_true() { when(keycloak.realm("test-realm")).thenReturn(realmResource); @@ -156,22 +139,16 @@ class KeycloakAdminClientImplTest { when(realmResource.roles()).thenReturn(rolesResource); when(rolesResource.list()).thenThrow(new RuntimeException("Some other error")); - // Should return true assuming realm exists but has issues assertTrue(client.realmExists("problem-realm")); } @Test void testClose() { - client.close(); - - verify(keycloak).close(); + assertDoesNotThrow(() -> client.close()); } @Test - void testCloseWhenNull() throws Exception { - setField(client, "keycloak", null); - - // Should not throw - client.close(); + void testReconnect() { + assertDoesNotThrow(() -> client.reconnect()); } } diff --git a/src/test/java/dev/lions/user/manager/config/JacksonConfigTest.java b/src/test/java/dev/lions/user/manager/config/JacksonConfigTest.java deleted file mode 100644 index f4ef756..0000000 --- a/src/test/java/dev/lions/user/manager/config/JacksonConfigTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package dev.lions.user.manager.config; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.quarkus.jackson.ObjectMapperCustomizer; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Tests unitaires pour JacksonConfig - */ -class JacksonConfigTest { - - private JacksonConfig jacksonConfig; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - jacksonConfig = new JacksonConfig(); - objectMapper = new ObjectMapper(); - } - - @Test - void testCustomize() { - // Avant la personnalisation, FAIL_ON_UNKNOWN_PROPERTIES devrait être true par défaut - assertTrue(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)); - - // Appliquer la personnalisation - jacksonConfig.customize(objectMapper); - - // Après la personnalisation, FAIL_ON_UNKNOWN_PROPERTIES devrait être false - assertFalse(objectMapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)); - } - - @Test - void testImplementsObjectMapperCustomizer() { - assertTrue(jacksonConfig instanceof ObjectMapperCustomizer); - } -} diff --git a/src/test/java/dev/lions/user/manager/resource/AuditResourceTest.java b/src/test/java/dev/lions/user/manager/resource/AuditResourceTest.java index d297c61..a2539ca 100644 --- a/src/test/java/dev/lions/user/manager/resource/AuditResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/AuditResourceTest.java @@ -1,6 +1,7 @@ package dev.lions.user.manager.resource; import dev.lions.user.manager.dto.audit.AuditLogDTO; +import dev.lions.user.manager.dto.common.CountDTO; import dev.lions.user.manager.enums.audit.TypeActionAudit; import dev.lions.user.manager.service.AuditService; import jakarta.ws.rs.core.Response; @@ -10,7 +11,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Map; @@ -32,33 +32,11 @@ class AuditResourceTest { void testSearchLogs() { List logs = Collections.singletonList( AuditLogDTO.builder().acteurUsername("admin").typeAction(TypeActionAudit.USER_CREATE).build()); - when(auditService.findByActeur(eq("admin"), isNull(), isNull(), eq(0), eq(50))).thenReturn(logs); + when(auditService.findByActeur(eq("admin"), any(), any(), eq(0), eq(50))).thenReturn(logs); - Response response = auditResource.searchLogs("admin", null, null, null, null, null, 0, 50); + List result = auditResource.searchLogs("admin", null, null, null, null, null, 0, 50); - assertEquals(200, response.getStatus()); - assertEquals(logs, response.getEntity()); - } - - @Test - void testSearchLogsWithDates() { - List logs = Collections.emptyList(); - when(auditService.findByRealm(eq("master"), any(), any(), eq(0), eq(50))).thenReturn(logs); - - Response response = auditResource.searchLogs(null, "2024-01-01T00:00:00", "2024-12-31T23:59:59", - TypeActionAudit.USER_CREATE, null, true, 0, 50); - - assertEquals(200, response.getStatus()); - } - - @Test - void testSearchLogsError() { - when(auditService.findByRealm(eq("master"), isNull(), isNull(), eq(0), eq(50))) - .thenThrow(new RuntimeException("Error")); - - Response response = auditResource.searchLogs(null, null, null, null, null, null, 0, 50); - - assertEquals(500, response.getStatus()); + assertEquals(logs, result); } @Test @@ -67,204 +45,89 @@ class AuditResourceTest { AuditLogDTO.builder().acteurUsername("admin").build()); when(auditService.findByActeur(eq("admin"), isNull(), isNull(), eq(0), eq(100))).thenReturn(logs); - Response response = auditResource.getLogsByActor("admin", 100); + List result = auditResource.getLogsByActor("admin", 100); - assertEquals(200, response.getStatus()); - assertEquals(logs, response.getEntity()); - } - - @Test - void testGetLogsByActorError() { - when(auditService.findByActeur(eq("admin"), isNull(), isNull(), eq(0), eq(100))) - .thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getLogsByActor("admin", 100); - - assertEquals(500, response.getStatus()); + assertEquals(logs, result); } @Test void testGetLogsByResource() { List logs = Collections.emptyList(); - when(auditService.findByRessource(eq("USER"), eq("1"), isNull(), isNull(), eq(0), eq(100))) + when(auditService.findByRessource(eq("USER"), eq("1"), any(), any(), eq(0), eq(100))) .thenReturn(logs); - Response response = auditResource.getLogsByResource("USER", "1", 100); + List result = auditResource.getLogsByResource("USER", "1", 100); - assertEquals(200, response.getStatus()); - assertEquals(logs, response.getEntity()); - } - - @Test - void testGetLogsByResourceError() { - when(auditService.findByRessource(eq("USER"), eq("1"), isNull(), isNull(), eq(0), eq(100))) - .thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getLogsByResource("USER", "1", 100); - - assertEquals(500, response.getStatus()); + assertEquals(logs, result); } @Test void testGetLogsByAction() { List logs = Collections.emptyList(); - when(auditService.findByTypeAction(eq(TypeActionAudit.USER_CREATE), eq("master"), isNull(), isNull(), eq(0), eq(100))) + when(auditService.findByTypeAction(eq(TypeActionAudit.USER_CREATE), eq("master"), any(), any(), eq(0), eq(100))) .thenReturn(logs); - Response response = auditResource.getLogsByAction(TypeActionAudit.USER_CREATE, null, null, 100); + List result = auditResource.getLogsByAction(TypeActionAudit.USER_CREATE, null, null, 100); - assertEquals(200, response.getStatus()); - } - - @Test - void testGetLogsByActionWithDates() { - List logs = Collections.emptyList(); - when(auditService.findByTypeAction(eq(TypeActionAudit.USER_UPDATE), eq("master"), any(), any(), eq(0), eq(50))) - .thenReturn(logs); - - Response response = auditResource.getLogsByAction(TypeActionAudit.USER_UPDATE, - "2024-01-01T00:00:00", "2024-12-31T23:59:59", 50); - - assertEquals(200, response.getStatus()); - } - - @Test - void testGetLogsByActionError() { - when(auditService.findByTypeAction(any(), eq("master"), any(), any(), anyInt(), anyInt())) - .thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getLogsByAction(TypeActionAudit.USER_CREATE, null, null, 100); - - assertEquals(500, response.getStatus()); + assertEquals(logs, result); } @Test void testGetActionStatistics() { Map stats = Map.of(TypeActionAudit.USER_CREATE, 10L); - when(auditService.countByActionType(eq("master"), isNull(), isNull())).thenReturn(stats); + when(auditService.countByActionType(eq("master"), any(), any())).thenReturn(stats); - Response response = auditResource.getActionStatistics(null, null); + Map result = auditResource.getActionStatistics(null, null); - assertEquals(200, response.getStatus()); - assertEquals(stats, response.getEntity()); - } - - @Test - void testGetActionStatisticsError() { - when(auditService.countByActionType(eq("master"), any(), any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getActionStatistics(null, null); - - assertEquals(500, response.getStatus()); + assertEquals(stats, result); } @Test void testGetUserActivityStatistics() { Map stats = Map.of("admin", 100L); - when(auditService.countByActeur(eq("master"), isNull(), isNull())).thenReturn(stats); + when(auditService.countByActeur(eq("master"), any(), any())).thenReturn(stats); - Response response = auditResource.getUserActivityStatistics(null, null); + Map result = auditResource.getUserActivityStatistics(null, null); - assertEquals(200, response.getStatus()); - assertEquals(stats, response.getEntity()); - } - - @Test - void testGetUserActivityStatisticsError() { - when(auditService.countByActeur(eq("master"), any(), any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getUserActivityStatistics(null, null); - - assertEquals(500, response.getStatus()); + assertEquals(stats, result); } @Test void testGetFailureCount() { Map successVsFailure = Map.of("failure", 5L, "success", 100L); - when(auditService.countSuccessVsFailure(eq("master"), isNull(), isNull())).thenReturn(successVsFailure); + when(auditService.countSuccessVsFailure(eq("master"), any(), any())).thenReturn(successVsFailure); - Response response = auditResource.getFailureCount(null, null); + CountDTO result = auditResource.getFailureCount(null, null); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testGetFailureCountError() { - when(auditService.countSuccessVsFailure(eq("master"), any(), any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getFailureCount(null, null); - - assertEquals(500, response.getStatus()); + assertEquals(5L, result.getCount()); } @Test void testGetSuccessCount() { Map successVsFailure = Map.of("failure", 5L, "success", 100L); - when(auditService.countSuccessVsFailure(eq("master"), isNull(), isNull())).thenReturn(successVsFailure); + when(auditService.countSuccessVsFailure(eq("master"), any(), any())).thenReturn(successVsFailure); - Response response = auditResource.getSuccessCount(null, null); + CountDTO result = auditResource.getSuccessCount(null, null); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testGetSuccessCountError() { - when(auditService.countSuccessVsFailure(eq("master"), any(), any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.getSuccessCount(null, null); - - assertEquals(500, response.getStatus()); + assertEquals(100L, result.getCount()); } @Test void testExportLogsToCSV() { - when(auditService.exportToCSV(eq("master"), isNull(), isNull())).thenReturn("csv,data"); + when(auditService.exportToCSV(eq("master"), any(), any())).thenReturn("csv,data"); Response response = auditResource.exportLogsToCSV(null, null); assertEquals(200, response.getStatus()); - } - - @Test - void testExportLogsToCSVError() { - when(auditService.exportToCSV(eq("master"), any(), any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.exportLogsToCSV(null, null); - - assertEquals(500, response.getStatus()); + assertEquals("csv,data", response.getEntity()); } @Test void testPurgeOldLogs() { - when(auditService.purgeOldLogs(any())).thenReturn(50L); + doNothing().when(auditService).purgeOldLogs(any()); - Response response = auditResource.purgeOldLogs(90); + auditResource.purgeOldLogs(90); - assertEquals(204, response.getStatus()); - } - - @Test - void testPurgeOldLogsError() { - when(auditService.purgeOldLogs(any())).thenThrow(new RuntimeException("Error")); - - Response response = auditResource.purgeOldLogs(90); - - assertEquals(500, response.getStatus()); - } - - // ============== Inner Class Tests ============== - - @Test - void testCountResponseClass() { - AuditResource.CountResponse response = new AuditResource.CountResponse(42); - assertEquals(42, response.count); - } - - @Test - void testErrorResponseClass() { - AuditResource.ErrorResponse response = new AuditResource.ErrorResponse("Error message"); - assertEquals("Error message", response.message); + verify(auditService).purgeOldLogs(any()); } } diff --git a/src/test/java/dev/lions/user/manager/resource/RealmAssignmentResourceTest.java b/src/test/java/dev/lions/user/manager/resource/RealmAssignmentResourceTest.java index 3d35087..78b029e 100644 --- a/src/test/java/dev/lions/user/manager/resource/RealmAssignmentResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/RealmAssignmentResourceTest.java @@ -1,5 +1,7 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.dto.realm.AuthorizedRealmsDTO; +import dev.lions.user.manager.dto.realm.RealmAccessCheckDTO; import dev.lions.user.manager.dto.realm.RealmAssignmentDTO; import dev.lions.user.manager.service.RealmAuthorizationService; import jakarta.ws.rs.core.Response; @@ -14,7 +16,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.security.Principal; import java.time.LocalDateTime; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; @@ -45,16 +46,16 @@ class RealmAssignmentResourceTest { @BeforeEach void setUp() { assignment = RealmAssignmentDTO.builder() - .id("assignment-1") - .userId("user-1") - .username("testuser") - .email("test@example.com") - .realmName("realm1") - .isSuperAdmin(false) - .active(true) - .assignedAt(LocalDateTime.now()) - .assignedBy("admin") - .build(); + .id("assignment-1") + .userId("user-1") + .username("testuser") + .email("test@example.com") + .realmName("realm1") + .isSuperAdmin(false) + .active(true) + .assignedAt(LocalDateTime.now()) + .assignedBy("admin") + .build(); } @Test @@ -62,21 +63,9 @@ class RealmAssignmentResourceTest { List assignments = Arrays.asList(assignment); when(realmAuthorizationService.getAllAssignments()).thenReturn(assignments); - Response response = realmAssignmentResource.getAllAssignments(); + List result = realmAssignmentResource.getAllAssignments(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - List responseAssignments = (List) response.getEntity(); - assertEquals(1, responseAssignments.size()); - } - - @Test - void testGetAllAssignments_Error() { - when(realmAuthorizationService.getAllAssignments()).thenThrow(new RuntimeException("Error")); - - Response response = realmAssignmentResource.getAllAssignments(); - - assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + assertEquals(1, result.size()); } @Test @@ -84,9 +73,9 @@ class RealmAssignmentResourceTest { List assignments = Arrays.asList(assignment); when(realmAuthorizationService.getAssignmentsByUser("user-1")).thenReturn(assignments); - Response response = realmAssignmentResource.getAssignmentsByUser("user-1"); + List result = realmAssignmentResource.getAssignmentsByUser("user-1"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(1, result.size()); } @Test @@ -94,38 +83,35 @@ class RealmAssignmentResourceTest { List assignments = Arrays.asList(assignment); when(realmAuthorizationService.getAssignmentsByRealm("realm1")).thenReturn(assignments); - Response response = realmAssignmentResource.getAssignmentsByRealm("realm1"); + List result = realmAssignmentResource.getAssignmentsByRealm("realm1"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(1, result.size()); } @Test void testGetAssignmentById_Success() { when(realmAuthorizationService.getAssignmentById("assignment-1")).thenReturn(Optional.of(assignment)); - Response response = realmAssignmentResource.getAssignmentById("assignment-1"); + RealmAssignmentDTO result = realmAssignmentResource.getAssignmentById("assignment-1"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertNotNull(result); + assertEquals("assignment-1", result.getId()); } @Test void testGetAssignmentById_NotFound() { when(realmAuthorizationService.getAssignmentById("non-existent")).thenReturn(Optional.empty()); - Response response = realmAssignmentResource.getAssignmentById("non-existent"); - - assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + assertThrows(RuntimeException.class, () -> realmAssignmentResource.getAssignmentById("non-existent")); } @Test void testCanManageRealm_Success() { when(realmAuthorizationService.canManageRealm("user-1", "realm1")).thenReturn(true); - Response response = realmAssignmentResource.canManageRealm("user-1", "realm1"); + RealmAccessCheckDTO result = realmAssignmentResource.canManageRealm("user-1", "realm1"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - RealmAssignmentResource.CheckResponse checkResponse = (RealmAssignmentResource.CheckResponse) response.getEntity(); - assertTrue(checkResponse.canManage); + assertTrue(result.isCanManage()); } @Test @@ -134,91 +120,70 @@ class RealmAssignmentResourceTest { when(realmAuthorizationService.getAuthorizedRealms("user-1")).thenReturn(realms); when(realmAuthorizationService.isSuperAdmin("user-1")).thenReturn(false); - Response response = realmAssignmentResource.getAuthorizedRealms("user-1"); + AuthorizedRealmsDTO result = realmAssignmentResource.getAuthorizedRealms("user-1"); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - RealmAssignmentResource.AuthorizedRealmsResponse authResponse = - (RealmAssignmentResource.AuthorizedRealmsResponse) response.getEntity(); - assertEquals(2, authResponse.realms.size()); - assertFalse(authResponse.isSuperAdmin); + assertEquals(2, result.getRealms().size()); + assertFalse(result.isSuperAdmin()); } @Test void testAssignRealmToUser_Success() { + // En Quarkus, @Context SecurityContext injecté peut être simulé via Mockito + // Mais dans RealmAssignmentResource, l'admin est récupéré du SecurityContext. + // Puisque c'est un test unitaire @ExtendWith(MockitoExtension.class), + // @Inject SecurityContext securityContext est mocké. + when(securityContext.getUserPrincipal()).thenReturn(principal); when(principal.getName()).thenReturn("admin"); when(realmAuthorizationService.assignRealmToUser(any(RealmAssignmentDTO.class))).thenReturn(assignment); Response response = realmAssignmentResource.assignRealmToUser(assignment); - assertEquals(Response.Status.CREATED.getStatusCode(), response.getStatus()); - } - - @Test - void testAssignRealmToUser_Conflict() { - when(securityContext.getUserPrincipal()).thenReturn(principal); - when(principal.getName()).thenReturn("admin"); - when(realmAuthorizationService.assignRealmToUser(any(RealmAssignmentDTO.class))) - .thenThrow(new IllegalArgumentException("Already exists")); - - Response response = realmAssignmentResource.assignRealmToUser(assignment); - - assertEquals(Response.Status.CONFLICT.getStatusCode(), response.getStatus()); + assertEquals(201, response.getStatus()); } @Test void testRevokeRealmFromUser_Success() { doNothing().when(realmAuthorizationService).revokeRealmFromUser("user-1", "realm1"); - Response response = realmAssignmentResource.revokeRealmFromUser("user-1", "realm1"); + realmAssignmentResource.revokeRealmFromUser("user-1", "realm1"); - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + verify(realmAuthorizationService).revokeRealmFromUser("user-1", "realm1"); } @Test void testRevokeAllRealmsFromUser_Success() { doNothing().when(realmAuthorizationService).revokeAllRealmsFromUser("user-1"); - Response response = realmAssignmentResource.revokeAllRealmsFromUser("user-1"); + realmAssignmentResource.revokeAllRealmsFromUser("user-1"); - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + verify(realmAuthorizationService).revokeAllRealmsFromUser("user-1"); } @Test void testDeactivateAssignment_Success() { doNothing().when(realmAuthorizationService).deactivateAssignment("assignment-1"); - Response response = realmAssignmentResource.deactivateAssignment("assignment-1"); + realmAssignmentResource.deactivateAssignment("assignment-1"); - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); - } - - @Test - void testDeactivateAssignment_NotFound() { - doThrow(new IllegalArgumentException("Not found")) - .when(realmAuthorizationService).deactivateAssignment("non-existent"); - - Response response = realmAssignmentResource.deactivateAssignment("non-existent"); - - assertEquals(Response.Status.NOT_FOUND.getStatusCode(), response.getStatus()); + verify(realmAuthorizationService).deactivateAssignment("assignment-1"); } @Test void testActivateAssignment_Success() { doNothing().when(realmAuthorizationService).activateAssignment("assignment-1"); - Response response = realmAssignmentResource.activateAssignment("assignment-1"); + realmAssignmentResource.activateAssignment("assignment-1"); - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + verify(realmAuthorizationService).activateAssignment("assignment-1"); } @Test void testSetSuperAdmin_Success() { doNothing().when(realmAuthorizationService).setSuperAdmin("user-1", true); - Response response = realmAssignmentResource.setSuperAdmin("user-1", true); + realmAssignmentResource.setSuperAdmin("user-1", true); - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); + verify(realmAuthorizationService).setSuperAdmin("user-1", true); } } - diff --git a/src/test/java/dev/lions/user/manager/resource/RealmResourceAdditionalTest.java b/src/test/java/dev/lions/user/manager/resource/RealmResourceAdditionalTest.java index 31a0ce3..88a1691 100644 --- a/src/test/java/dev/lions/user/manager/resource/RealmResourceAdditionalTest.java +++ b/src/test/java/dev/lions/user/manager/resource/RealmResourceAdditionalTest.java @@ -2,8 +2,6 @@ package dev.lions.user.manager.resource; import dev.lions.user.manager.client.KeycloakAdminClient; import io.quarkus.security.identity.SecurityIdentity; -import jakarta.ws.rs.core.Response; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -31,43 +29,31 @@ class RealmResourceAdditionalTest { @InjectMocks private RealmResource realmResource; - @BeforeEach - void setUp() { - // Setup - } - @Test void testGetAllRealms_Success() { List realms = Arrays.asList("master", "lions-user-manager", "test-realm"); when(keycloakAdminClient.getAllRealms()).thenReturn(realms); - Response response = realmResource.getAllRealms(); + List result = realmResource.getAllRealms(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - List responseRealms = (List) response.getEntity(); - assertEquals(3, responseRealms.size()); + assertNotNull(result); + assertEquals(3, result.size()); } @Test void testGetAllRealms_Empty() { when(keycloakAdminClient.getAllRealms()).thenReturn(List.of()); - Response response = realmResource.getAllRealms(); + List result = realmResource.getAllRealms(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - List responseRealms = (List) response.getEntity(); - assertTrue(responseRealms.isEmpty()); + assertNotNull(result); + assertTrue(result.isEmpty()); } @Test void testGetAllRealms_Exception() { when(keycloakAdminClient.getAllRealms()).thenThrow(new RuntimeException("Connection error")); - Response response = realmResource.getAllRealms(); - - assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + assertThrows(RuntimeException.class, () -> realmResource.getAllRealms()); } } - diff --git a/src/test/java/dev/lions/user/manager/resource/RealmResourceTest.java b/src/test/java/dev/lions/user/manager/resource/RealmResourceTest.java index ae36359..d5a0dbf 100644 --- a/src/test/java/dev/lions/user/manager/resource/RealmResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/RealmResourceTest.java @@ -2,14 +2,12 @@ package dev.lions.user.manager.resource; import dev.lions.user.manager.client.KeycloakAdminClient; import io.quarkus.security.identity.SecurityIdentity; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import jakarta.ws.rs.core.Response; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -32,24 +30,16 @@ class RealmResourceTest { @InjectMocks private RealmResource realmResource; - @BeforeEach - void setUp() { - // Setup initial - } - @Test void testGetAllRealms_Success() { List realms = Arrays.asList("master", "lions-user-manager", "btpxpress"); when(keycloakAdminClient.getAllRealms()).thenReturn(realms); - Response response = realmResource.getAllRealms(); + List result = realmResource.getAllRealms(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - List responseRealms = (List) response.getEntity(); - assertNotNull(responseRealms); - assertEquals(3, responseRealms.size()); - assertEquals("master", responseRealms.get(0)); + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("master", result.get(0)); verify(keycloakAdminClient).getAllRealms(); } @@ -57,34 +47,16 @@ class RealmResourceTest { void testGetAllRealms_EmptyList() { when(keycloakAdminClient.getAllRealms()).thenReturn(Collections.emptyList()); - Response response = realmResource.getAllRealms(); + List result = realmResource.getAllRealms(); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); - @SuppressWarnings("unchecked") - List responseRealms = (List) response.getEntity(); - assertNotNull(responseRealms); - assertTrue(responseRealms.isEmpty()); + assertNotNull(result); + assertTrue(result.isEmpty()); } @Test void testGetAllRealms_Exception() { when(keycloakAdminClient.getAllRealms()).thenThrow(new RuntimeException("Keycloak connection error")); - Response response = realmResource.getAllRealms(); - - assertEquals(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); - RealmResource.ErrorResponse errorResponse = (RealmResource.ErrorResponse) response.getEntity(); - assertNotNull(errorResponse); - assertTrue(errorResponse.getMessage().contains("Erreur lors de la récupération des realms")); - } - - @Test - void testErrorResponse() { - RealmResource.ErrorResponse errorResponse = new RealmResource.ErrorResponse("Test error"); - assertEquals("Test error", errorResponse.getMessage()); - - errorResponse.setMessage("New error"); - assertEquals("New error", errorResponse.getMessage()); + assertThrows(RuntimeException.class, () -> realmResource.getAllRealms()); } } - diff --git a/src/test/java/dev/lions/user/manager/resource/RoleResourceTest.java b/src/test/java/dev/lions/user/manager/resource/RoleResourceTest.java index eeadce3..bcf3dce 100644 --- a/src/test/java/dev/lions/user/manager/resource/RoleResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/RoleResourceTest.java @@ -1,6 +1,7 @@ package dev.lions.user.manager.resource; import dev.lions.user.manager.dto.role.RoleAssignmentDTO; +import dev.lions.user.manager.dto.role.RoleAssignmentRequestDTO; import dev.lions.user.manager.dto.role.RoleDTO; import dev.lions.user.manager.enums.role.TypeRole; import dev.lions.user.manager.service.RoleService; @@ -58,28 +59,15 @@ class RoleResourceTest { assertEquals(409, response.getStatus()); } - @Test - void testCreateRealmRoleError() { - RoleDTO input = RoleDTO.builder().name("role").build(); - - when(roleService.createRealmRole(any(), eq(REALM))) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.createRealmRole(input, REALM); - - assertEquals(500, response.getStatus()); - } - @Test void testGetRealmRole() { RoleDTO role = RoleDTO.builder().id("1").name("role").build(); when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(Optional.of(role)); - Response response = roleResource.getRealmRole("role", REALM); + RoleDTO result = roleResource.getRealmRole("role", REALM); - assertEquals(200, response.getStatus()); - assertEquals(role, response.getEntity()); + assertEquals(role, result); } @Test @@ -87,19 +75,7 @@ class RoleResourceTest { when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(Optional.empty()); - Response response = roleResource.getRealmRole("role", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testGetRealmRoleError() { - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getRealmRole("role", REALM); - - assertEquals(500, response.getStatus()); + assertThrows(RuntimeException.class, () -> roleResource.getRealmRole("role", REALM)); } @Test @@ -107,19 +83,9 @@ class RoleResourceTest { List roles = Collections.singletonList(RoleDTO.builder().name("role").build()); when(roleService.getAllRealmRoles(REALM)).thenReturn(roles); - Response response = roleResource.getAllRealmRoles(REALM); + List result = roleResource.getAllRealmRoles(REALM); - assertEquals(200, response.getStatus()); - assertEquals(roles, response.getEntity()); - } - - @Test - void testGetAllRealmRolesError() { - when(roleService.getAllRealmRoles(REALM)).thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getAllRealmRoles(REALM); - - assertEquals(500, response.getStatus()); + assertEquals(roles, result); } @Test @@ -133,37 +99,9 @@ class RoleResourceTest { when(roleService.updateRole(eq("1"), any(), eq(REALM), eq(TypeRole.REALM_ROLE), isNull())) .thenReturn(updated); - Response response = roleResource.updateRealmRole("role", input, REALM); + RoleDTO result = roleResource.updateRealmRole("role", input, REALM); - assertEquals(200, response.getStatus()); - assertEquals(updated, response.getEntity()); - } - - @Test - void testUpdateRealmRoleNotFound() { - RoleDTO input = RoleDTO.builder().description("updated").build(); - - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.empty()); - - Response response = roleResource.updateRealmRole("role", input, REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testUpdateRealmRoleError() { - RoleDTO existingRole = RoleDTO.builder().id("1").name("role").build(); - RoleDTO input = RoleDTO.builder().description("updated").build(); - - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.of(existingRole)); - when(roleService.updateRole(eq("1"), any(), eq(REALM), eq(TypeRole.REALM_ROLE), isNull())) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.updateRealmRole("role", input, REALM); - - assertEquals(500, response.getStatus()); + assertEquals(updated, result); } @Test @@ -173,32 +111,9 @@ class RoleResourceTest { .thenReturn(Optional.of(existingRole)); doNothing().when(roleService).deleteRole(eq("1"), eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); - Response response = roleResource.deleteRealmRole("role", REALM); + roleResource.deleteRealmRole("role", REALM); - assertEquals(204, response.getStatus()); - } - - @Test - void testDeleteRealmRoleNotFound() { - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.empty()); - - Response response = roleResource.deleteRealmRole("role", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testDeleteRealmRoleError() { - RoleDTO existingRole = RoleDTO.builder().id("1").name("role").build(); - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.of(existingRole)); - doThrow(new RuntimeException("Error")).when(roleService) - .deleteRole(eq("1"), eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); - - Response response = roleResource.deleteRealmRole("role", REALM); - - assertEquals(500, response.getStatus()); + verify(roleService).deleteRole(eq("1"), eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); } // ============== Client Role Tests ============== @@ -208,7 +123,7 @@ class RoleResourceTest { RoleDTO input = RoleDTO.builder().name("role").build(); RoleDTO created = RoleDTO.builder().id("1").name("role").build(); - when(roleService.createClientRole(any(RoleDTO.class), eq(REALM), eq(CLIENT_ID))).thenReturn(created); + when(roleService.createClientRole(any(RoleDTO.class), eq(CLIENT_ID), eq(REALM))).thenReturn(created); Response response = roleResource.createClientRole(CLIENT_ID, input, REALM); @@ -216,48 +131,15 @@ class RoleResourceTest { assertEquals(created, response.getEntity()); } - @Test - void testCreateClientRoleError() { - RoleDTO input = RoleDTO.builder().name("role").build(); - - when(roleService.createClientRole(any(RoleDTO.class), eq(REALM), eq(CLIENT_ID))) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.createClientRole(CLIENT_ID, input, REALM); - - assertEquals(500, response.getStatus()); - } - @Test void testGetClientRole() { RoleDTO role = RoleDTO.builder().id("1").name("role").build(); when(roleService.getRoleByName("role", REALM, TypeRole.CLIENT_ROLE, CLIENT_ID)) .thenReturn(Optional.of(role)); - Response response = roleResource.getClientRole(CLIENT_ID, "role", REALM); + RoleDTO result = roleResource.getClientRole(CLIENT_ID, "role", REALM); - assertEquals(200, response.getStatus()); - assertEquals(role, response.getEntity()); - } - - @Test - void testGetClientRoleNotFound() { - when(roleService.getRoleByName("role", REALM, TypeRole.CLIENT_ROLE, CLIENT_ID)) - .thenReturn(Optional.empty()); - - Response response = roleResource.getClientRole(CLIENT_ID, "role", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testGetClientRoleError() { - when(roleService.getRoleByName("role", REALM, TypeRole.CLIENT_ROLE, CLIENT_ID)) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getClientRole(CLIENT_ID, "role", REALM); - - assertEquals(500, response.getStatus()); + assertEquals(role, result); } @Test @@ -265,19 +147,9 @@ class RoleResourceTest { List roles = Collections.singletonList(RoleDTO.builder().name("role").build()); when(roleService.getAllClientRoles(REALM, CLIENT_ID)).thenReturn(roles); - Response response = roleResource.getAllClientRoles(CLIENT_ID, REALM); + List result = roleResource.getAllClientRoles(CLIENT_ID, REALM); - assertEquals(200, response.getStatus()); - assertEquals(roles, response.getEntity()); - } - - @Test - void testGetAllClientRolesError() { - when(roleService.getAllClientRoles(REALM, CLIENT_ID)).thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getAllClientRoles(CLIENT_ID, REALM); - - assertEquals(500, response.getStatus()); + assertEquals(roles, result); } @Test @@ -287,32 +159,9 @@ class RoleResourceTest { .thenReturn(Optional.of(existingRole)); doNothing().when(roleService).deleteRole(eq("1"), eq(REALM), eq(TypeRole.CLIENT_ROLE), eq(CLIENT_ID)); - Response response = roleResource.deleteClientRole(CLIENT_ID, "role", REALM); + roleResource.deleteClientRole(CLIENT_ID, "role", REALM); - assertEquals(204, response.getStatus()); - } - - @Test - void testDeleteClientRoleNotFound() { - when(roleService.getRoleByName("role", REALM, TypeRole.CLIENT_ROLE, CLIENT_ID)) - .thenReturn(Optional.empty()); - - Response response = roleResource.deleteClientRole(CLIENT_ID, "role", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testDeleteClientRoleError() { - RoleDTO existingRole = RoleDTO.builder().id("1").name("role").build(); - when(roleService.getRoleByName("role", REALM, TypeRole.CLIENT_ROLE, CLIENT_ID)) - .thenReturn(Optional.of(existingRole)); - doThrow(new RuntimeException("Error")).when(roleService) - .deleteRole(eq("1"), eq(REALM), eq(TypeRole.CLIENT_ROLE), eq(CLIENT_ID)); - - Response response = roleResource.deleteClientRole(CLIENT_ID, "role", REALM); - - assertEquals(500, response.getStatus()); + verify(roleService).deleteRole(eq("1"), eq(REALM), eq(TypeRole.CLIENT_ROLE), eq(CLIENT_ID)); } // ============== Role Assignment Tests ============== @@ -321,95 +170,49 @@ class RoleResourceTest { void testAssignRealmRoles() { doNothing().when(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); + RoleAssignmentRequestDTO request = RoleAssignmentRequestDTO.builder() + .roleNames(Collections.singletonList("role")) + .build(); - Response response = roleResource.assignRealmRoles("user1", REALM, request); + roleResource.assignRealmRoles("user1", REALM, request); - assertEquals(204, response.getStatus()); verify(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); } - @Test - void testAssignRealmRolesError() { - doThrow(new RuntimeException("Error")).when(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); - - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); - - Response response = roleResource.assignRealmRoles("user1", REALM, request); - - assertEquals(500, response.getStatus()); - } - @Test void testRevokeRealmRoles() { doNothing().when(roleService).revokeRolesFromUser(any(RoleAssignmentDTO.class)); - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); + RoleAssignmentRequestDTO request = RoleAssignmentRequestDTO.builder() + .roleNames(Collections.singletonList("role")) + .build(); - Response response = roleResource.revokeRealmRoles("user1", REALM, request); + roleResource.revokeRealmRoles("user1", REALM, request); - assertEquals(204, response.getStatus()); verify(roleService).revokeRolesFromUser(any(RoleAssignmentDTO.class)); } - @Test - void testRevokeRealmRolesError() { - doThrow(new RuntimeException("Error")).when(roleService).revokeRolesFromUser(any(RoleAssignmentDTO.class)); - - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); - - Response response = roleResource.revokeRealmRoles("user1", REALM, request); - - assertEquals(500, response.getStatus()); - } - @Test void testAssignClientRoles() { doNothing().when(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); + RoleAssignmentRequestDTO request = RoleAssignmentRequestDTO.builder() + .roleNames(Collections.singletonList("role")) + .build(); - Response response = roleResource.assignClientRoles(CLIENT_ID, "user1", REALM, request); + roleResource.assignClientRoles(CLIENT_ID, "user1", REALM, request); - assertEquals(204, response.getStatus()); verify(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); } - @Test - void testAssignClientRolesError() { - doThrow(new RuntimeException("Error")).when(roleService).assignRolesToUser(any(RoleAssignmentDTO.class)); - - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("role"); - - Response response = roleResource.assignClientRoles(CLIENT_ID, "user1", REALM, request); - - assertEquals(500, response.getStatus()); - } - @Test void testGetUserRealmRoles() { List roles = Collections.singletonList(RoleDTO.builder().name("role").build()); when(roleService.getUserRealmRoles("user1", REALM)).thenReturn(roles); - Response response = roleResource.getUserRealmRoles("user1", REALM); + List result = roleResource.getUserRealmRoles("user1", REALM); - assertEquals(200, response.getStatus()); - assertEquals(roles, response.getEntity()); - } - - @Test - void testGetUserRealmRolesError() { - when(roleService.getUserRealmRoles("user1", REALM)).thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getUserRealmRoles("user1", REALM); - - assertEquals(500, response.getStatus()); + assertEquals(roles, result); } @Test @@ -417,19 +220,9 @@ class RoleResourceTest { List roles = Collections.singletonList(RoleDTO.builder().name("role").build()); when(roleService.getUserClientRoles("user1", CLIENT_ID, REALM)).thenReturn(roles); - Response response = roleResource.getUserClientRoles(CLIENT_ID, "user1", REALM); + List result = roleResource.getUserClientRoles(CLIENT_ID, "user1", REALM); - assertEquals(200, response.getStatus()); - assertEquals(roles, response.getEntity()); - } - - @Test - void testGetUserClientRolesError() { - when(roleService.getUserClientRoles("user1", CLIENT_ID, REALM)).thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getUserClientRoles(CLIENT_ID, "user1", REALM); - - assertEquals(500, response.getStatus()); + assertEquals(roles, result); } // ============== Composite Role Tests ============== @@ -438,104 +231,36 @@ class RoleResourceTest { void testAddComposites() { RoleDTO parentRole = RoleDTO.builder().id("parent-1").name("role").build(); RoleDTO childRole = RoleDTO.builder().id("child-1").name("composite").build(); - + when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(Optional.of(parentRole)); when(roleService.getRoleByName("composite", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(Optional.of(childRole)); - doNothing().when(roleService).addCompositeRoles(eq("parent-1"), anyList(), eq(REALM), + doNothing().when(roleService).addCompositeRoles(eq("parent-1"), anyList(), eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("composite"); + RoleAssignmentRequestDTO request = RoleAssignmentRequestDTO.builder() + .roleNames(Collections.singletonList("composite")) + .build(); - Response response = roleResource.addComposites("role", REALM, request); + roleResource.addComposites("role", REALM, request); - assertEquals(204, response.getStatus()); - verify(roleService).addCompositeRoles(eq("parent-1"), anyList(), eq(REALM), + verify(roleService).addCompositeRoles(eq("parent-1"), anyList(), eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); } - @Test - void testAddCompositesParentNotFound() { - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.empty()); - - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("composite"); - - Response response = roleResource.addComposites("role", REALM, request); - - assertEquals(404, response.getStatus()); - } - - @Test - void testAddCompositesError() { - RoleDTO parentRole = RoleDTO.builder().id("parent-1").name("role").build(); - RoleDTO childRole = RoleDTO.builder().id("child-1").name("composite").build(); - - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.of(parentRole)); - when(roleService.getRoleByName("composite", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.of(childRole)); - doThrow(new RuntimeException("Error")).when(roleService).addCompositeRoles(eq("parent-1"), anyList(), - eq(REALM), eq(TypeRole.REALM_ROLE), isNull()); - - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = Collections.singletonList("composite"); - - Response response = roleResource.addComposites("role", REALM, request); - - assertEquals(500, response.getStatus()); - } - @Test void testGetComposites() { RoleDTO role = RoleDTO.builder().id("1").name("role").build(); List composites = Collections.singletonList(RoleDTO.builder().name("composite").build()); - + when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(Optional.of(role)); when(roleService.getCompositeRoles("1", REALM, TypeRole.REALM_ROLE, null)) .thenReturn(composites); - Response response = roleResource.getComposites("role", REALM); + List result = roleResource.getComposites("role", REALM); - assertEquals(200, response.getStatus()); - assertEquals(composites, response.getEntity()); - } - - @Test - void testGetCompositesNotFound() { - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.empty()); - - Response response = roleResource.getComposites("role", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testGetCompositesError() { - RoleDTO role = RoleDTO.builder().id("1").name("role").build(); - when(roleService.getRoleByName("role", REALM, TypeRole.REALM_ROLE, null)) - .thenReturn(Optional.of(role)); - when(roleService.getCompositeRoles("1", REALM, TypeRole.REALM_ROLE, null)) - .thenThrow(new RuntimeException("Error")); - - Response response = roleResource.getComposites("role", REALM); - - assertEquals(500, response.getStatus()); - } - - // ============== Inner Class Tests ============== - - @Test - void testRoleAssignmentRequestClass() { - RoleResource.RoleAssignmentRequest request = new RoleResource.RoleAssignmentRequest(); - request.roleNames = List.of("role1", "role2"); - - assertEquals(2, request.roleNames.size()); - assertTrue(request.roleNames.contains("role1")); + assertEquals(composites, result); } } diff --git a/src/test/java/dev/lions/user/manager/resource/SyncResourceTest.java b/src/test/java/dev/lions/user/manager/resource/SyncResourceTest.java index 9f30ea8..7287cf0 100644 --- a/src/test/java/dev/lions/user/manager/resource/SyncResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/SyncResourceTest.java @@ -1,15 +1,15 @@ package dev.lions.user.manager.resource; +import dev.lions.user.manager.api.SyncResourceApi; +import dev.lions.user.manager.dto.sync.HealthStatusDTO; +import dev.lions.user.manager.dto.sync.SyncResultDTO; import dev.lions.user.manager.service.SyncService; -import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Collections; -import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -26,228 +26,57 @@ class SyncResourceTest { SyncResource syncResource; private static final String REALM = "test-realm"; - private static final String CLIENT_ID = "test-client"; + + @Test + void testCheckKeycloakHealth() { + when(syncService.isKeycloakAvailable()).thenReturn(true); + when(syncService.getKeycloakHealthInfo()).thenReturn(Map.of("version", "23.0.0")); + + HealthStatusDTO status = syncResource.checkKeycloakHealth(); + + assertTrue(status.isKeycloakAccessible()); + assertTrue(status.isOverallHealthy()); + assertEquals("23.0.0", status.getKeycloakVersion()); + } + + @Test + void testCheckKeycloakHealthError() { + when(syncService.isKeycloakAvailable()).thenThrow(new RuntimeException("Connection refused")); + + HealthStatusDTO status = syncResource.checkKeycloakHealth(); + + assertFalse(status.isOverallHealthy()); + assertTrue(status.getErrorMessage().contains("Connection refused")); + } @Test void testSyncUsers() { when(syncService.syncUsersFromRealm(REALM)).thenReturn(10); - Response response = syncResource.syncUsers(REALM); + SyncResultDTO result = syncResource.syncUsers(REALM); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); + assertTrue(result.isSuccess()); + assertEquals(10, result.getUsersCount()); + assertEquals(REALM, result.getRealmName()); } @Test void testSyncUsersError() { - when(syncService.syncUsersFromRealm(REALM)).thenThrow(new RuntimeException("Error")); + when(syncService.syncUsersFromRealm(REALM)).thenThrow(new RuntimeException("Sync failed")); - Response response = syncResource.syncUsers(REALM); + SyncResultDTO result = syncResource.syncUsers(REALM); - assertEquals(500, response.getStatus()); + assertFalse(result.isSuccess()); + assertEquals("Sync failed", result.getErrorMessage()); } @Test - void testSyncRealmRoles() { + void testSyncRoles() { when(syncService.syncRolesFromRealm(REALM)).thenReturn(5); - Response response = syncResource.syncRealmRoles(REALM); + SyncResultDTO result = syncResource.syncRoles(REALM, null); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testSyncRealmRolesError() { - when(syncService.syncRolesFromRealm(REALM)).thenThrow(new RuntimeException("Error")); - - Response response = syncResource.syncRealmRoles(REALM); - - assertEquals(500, response.getStatus()); - } - - @Test - void testSyncClientRoles() { - when(syncService.syncRolesFromRealm(REALM)).thenReturn(3); - - Response response = syncResource.syncClientRoles(CLIENT_ID, REALM); - - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testSyncClientRolesError() { - when(syncService.syncRolesFromRealm(REALM)).thenThrow(new RuntimeException("Error")); - - Response response = syncResource.syncClientRoles(CLIENT_ID, REALM); - - assertEquals(500, response.getStatus()); - } - - @Test - void testSyncAll() { - Map result = Map.of( - "realmName", REALM, - "usersSynced", 10, - "rolesSynced", 5, - "success", true - ); - when(syncService.forceSyncRealm(REALM)).thenReturn(result); - - Response response = syncResource.syncAll(REALM); - - assertEquals(200, response.getStatus()); - assertEquals(result, response.getEntity()); - } - - @Test - void testSyncAllError() { - when(syncService.forceSyncRealm(REALM)).thenThrow(new RuntimeException("Error")); - - Response response = syncResource.syncAll(REALM); - - assertEquals(500, response.getStatus()); - } - - @Test - void testCheckHealthHealthy() { - when(syncService.isKeycloakAvailable()).thenReturn(true); - - Response response = syncResource.checkHealth(); - - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testCheckHealthUnhealthy() { - when(syncService.isKeycloakAvailable()).thenReturn(false); - - Response response = syncResource.checkHealth(); - - assertEquals(503, response.getStatus()); - } - - @Test - void testCheckHealthError() { - when(syncService.isKeycloakAvailable()).thenThrow(new RuntimeException("Error")); - - Response response = syncResource.checkHealth(); - - assertEquals(503, response.getStatus()); - } - - @Test - void testGetDetailedHealthStatus() { - Map status = Map.of( - "keycloakAvailable", true, - "keycloakVersion", "21.0.0" - ); - when(syncService.getKeycloakHealthInfo()).thenReturn(status); - - Response response = syncResource.getDetailedHealthStatus(); - - assertEquals(200, response.getStatus()); - assertEquals(status, response.getEntity()); - } - - @Test - void testGetDetailedHealthStatusError() { - when(syncService.getKeycloakHealthInfo()).thenThrow(new RuntimeException("Error")); - - Response response = syncResource.getDetailedHealthStatus(); - - assertEquals(500, response.getStatus()); - } - - @Test - void testCheckRealmExistsTrue() { - when(syncService.syncUsersFromRealm(REALM)).thenReturn(5); - - Response response = syncResource.checkRealmExists(REALM); - - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testCheckRealmExistsFalse() { - when(syncService.syncUsersFromRealm(REALM)).thenThrow(new RuntimeException("Realm not found")); - - Response response = syncResource.checkRealmExists(REALM); - - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testCheckRealmExistsError() { - when(syncService.syncUsersFromRealm(REALM)).thenThrow(new RuntimeException("Unexpected error")); - - Response response = syncResource.checkRealmExists(REALM); - - // checkRealmExists catches all exceptions and returns 200 with exists=false - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testCheckUserExists() { - // La méthode checkUserExists retourne toujours false dans l'implémentation actuelle - Response response = syncResource.checkUserExists("user1", REALM); - - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testCheckUserExistsError() { - // Test d'erreur si une exception est levée - // Note: L'implémentation actuelle ne lève pas d'exception, mais testons quand même - Response response = syncResource.checkUserExists("user1", REALM); - - assertEquals(200, response.getStatus()); - } - - // ============== Inner Class Tests ============== - - @Test - void testSyncUsersResponseClass() { - SyncResource.SyncUsersResponse response = new SyncResource.SyncUsersResponse(1, null); - - assertEquals(1, response.count); - assertNull(response.users); - } - - @Test - void testSyncRolesResponseClass() { - SyncResource.SyncRolesResponse response = new SyncResource.SyncRolesResponse(1, null); - - assertEquals(1, response.count); - assertNull(response.roles); - } - - @Test - void testHealthCheckResponseClass() { - SyncResource.HealthCheckResponse response = new SyncResource.HealthCheckResponse(true, "OK"); - - assertTrue(response.healthy); - assertEquals("OK", response.message); - } - - @Test - void testExistsCheckResponseClass() { - SyncResource.ExistsCheckResponse response = new SyncResource.ExistsCheckResponse(true, "realm", "test"); - - assertTrue(response.exists); - assertEquals("realm", response.resourceType); - assertEquals("test", response.resourceId); - } - - @Test - void testErrorResponseClass() { - SyncResource.ErrorResponse response = new SyncResource.ErrorResponse("Error"); - assertEquals("Error", response.message); + assertTrue(result.isSuccess()); + assertEquals(5, result.getRealmRolesCount()); } } diff --git a/src/test/java/dev/lions/user/manager/resource/UserMetricsResourceTest.java b/src/test/java/dev/lions/user/manager/resource/UserMetricsResourceTest.java new file mode 100644 index 0000000..3e62f10 --- /dev/null +++ b/src/test/java/dev/lions/user/manager/resource/UserMetricsResourceTest.java @@ -0,0 +1,95 @@ +package dev.lions.user.manager.resource; + +import dev.lions.user.manager.client.KeycloakAdminClient; +import dev.lions.user.manager.dto.common.UserSessionStatsDTO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.UserRepresentation; +import org.junit.jupiter.api.Assertions; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserMetricsResourceTest { + + @Mock + KeycloakAdminClient keycloakAdminClient; + + @Mock + RealmResource realmResource; + + @Mock + UsersResource usersResource; + + @Mock + UserResource userResource1; + + @Mock + UserResource userResource2; + + @InjectMocks + UserMetricsResource userMetricsResource; + + @Test + void testGetUserSessionStats() { + // Préparer deux utilisateurs avec des sessions différentes + UserRepresentation u1 = new UserRepresentation(); + u1.setId("u1"); + UserRepresentation u2 = new UserRepresentation(); + u2.setId("u2"); + + when(keycloakAdminClient.getRealm("test-realm")).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.list()).thenReturn(List.of(u1, u2)); + + // u1 a 2 sessions, u2 en a 0 + when(usersResource.get("u1")).thenReturn(userResource1); + when(usersResource.get("u2")).thenReturn(userResource2); + when(userResource1.getUserSessions()).thenReturn(List.of(new org.keycloak.representations.idm.UserSessionRepresentation(), + new org.keycloak.representations.idm.UserSessionRepresentation())); + when(userResource2.getUserSessions()).thenReturn(List.of()); + + UserSessionStatsDTO stats = userMetricsResource.getUserSessionStats("test-realm"); + + assertNotNull(stats); + assertEquals("test-realm", stats.getRealmName()); + assertEquals(2L, stats.getTotalUsers()); + assertEquals(2L, stats.getActiveSessions()); // 2 sessions au total + assertEquals(1L, stats.getOnlineUsers()); // 1 utilisateur avec au moins une session + } + + @Test + void testGetUserSessionStats_DefaultRealm() { + when(keycloakAdminClient.getRealm("master")).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.list()).thenReturn(List.of()); + + UserSessionStatsDTO stats = userMetricsResource.getUserSessionStats(null); + + assertNotNull(stats); + assertEquals("master", stats.getRealmName()); + assertEquals(0L, stats.getTotalUsers()); + } + + @Test + void testGetUserSessionStats_OnError() { + when(keycloakAdminClient.getRealm(anyString())) + .thenThrow(new RuntimeException("KC error")); + + Assertions.assertThrows(RuntimeException.class, + () -> userMetricsResource.getUserSessionStats("realm")); + } +} + + diff --git a/src/test/java/dev/lions/user/manager/resource/UserResourceTest.java b/src/test/java/dev/lions/user/manager/resource/UserResourceTest.java index 85f6466..f892a9f 100644 --- a/src/test/java/dev/lions/user/manager/resource/UserResourceTest.java +++ b/src/test/java/dev/lions/user/manager/resource/UserResourceTest.java @@ -1,8 +1,6 @@ package dev.lions.user.manager.resource; -import dev.lions.user.manager.dto.user.UserDTO; -import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; -import dev.lions.user.manager.dto.user.UserSearchResultDTO; +import dev.lions.user.manager.dto.user.*; import dev.lions.user.manager.service.UserService; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; @@ -12,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Collections; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -45,23 +44,10 @@ class UserResourceTest { when(userService.searchUsers(any())).thenReturn(mockResult); - Response response = userResource.searchUsers(criteria); + UserSearchResultDTO result = userResource.searchUsers(criteria); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testSearchUsersError() { - UserSearchCriteriaDTO criteria = UserSearchCriteriaDTO.builder() - .realmName(REALM) - .build(); - - when(userService.searchUsers(any())).thenThrow(new RuntimeException("Search failed")); - - Response response = userResource.searchUsers(criteria); - - assertEquals(500, response.getStatus()); + assertNotNull(result); + assertEquals(1, result.getTotalCount()); } @Test @@ -69,28 +55,17 @@ class UserResourceTest { UserDTO user = UserDTO.builder().id("1").username("testuser").build(); when(userService.getUserById("1", REALM)).thenReturn(Optional.of(user)); - Response response = userResource.getUserById("1", REALM); + UserDTO result = userResource.getUserById("1", REALM); - assertEquals(200, response.getStatus()); - assertEquals(user, response.getEntity()); + assertNotNull(result); + assertEquals(user, result); } @Test void testGetUserByIdNotFound() { when(userService.getUserById("1", REALM)).thenReturn(Optional.empty()); - Response response = userResource.getUserById("1", REALM); - - assertEquals(404, response.getStatus()); - } - - @Test - void testGetUserByIdError() { - when(userService.getUserById("1", REALM)).thenThrow(new RuntimeException("Error")); - - Response response = userResource.getUserById("1", REALM); - - assertEquals(500, response.getStatus()); + assertThrows(RuntimeException.class, () -> userResource.getUserById("1", REALM)); } @Test @@ -101,18 +76,10 @@ class UserResourceTest { .build(); when(userService.getAllUsers(REALM, 0, 20)).thenReturn(mockResult); - Response response = userResource.getAllUsers(REALM, 0, 20); + UserSearchResultDTO result = userResource.getAllUsers(REALM, 0, 20); - assertEquals(200, response.getStatus()); - } - - @Test - void testGetAllUsersError() { - when(userService.getAllUsers(REALM, 0, 20)).thenThrow(new RuntimeException("Error")); - - Response response = userResource.getAllUsers(REALM, 0, 20); - - assertEquals(500, response.getStatus()); + assertNotNull(result); + assertEquals(0, result.getTotalCount()); } @Test @@ -128,17 +95,6 @@ class UserResourceTest { assertEquals(createdUser, response.getEntity()); } - @Test - void testCreateUserError() { - UserDTO newUser = UserDTO.builder().username("newuser").email("new@test.com").build(); - - when(userService.createUser(any(), eq(REALM))).thenThrow(new RuntimeException("Create failed")); - - Response response = userResource.createUser(newUser, REALM); - - assertEquals(500, response.getStatus()); - } - @Test void testUpdateUser() { UserDTO updateUser = UserDTO.builder() @@ -157,197 +113,80 @@ class UserResourceTest { when(userService.updateUser(eq("1"), any(), eq(REALM))).thenReturn(updatedUser); - Response response = userResource.updateUser("1", updateUser, REALM); + UserDTO result = userResource.updateUser("1", updateUser, REALM); - assertEquals(200, response.getStatus()); - assertEquals(updatedUser, response.getEntity()); - } - - @Test - void testUpdateUserError() { - UserDTO updateUser = UserDTO.builder() - .username("updated") - .prenom("John") - .nom("Doe") - .email("john.doe@test.com") - .build(); - - when(userService.updateUser(eq("1"), any(), eq(REALM))).thenThrow(new RuntimeException("Update failed")); - - Response response = userResource.updateUser("1", updateUser, REALM); - - assertEquals(500, response.getStatus()); + assertNotNull(result); + assertEquals(updatedUser, result); } @Test void testDeleteUser() { doNothing().when(userService).deleteUser("1", REALM, false); - Response response = userResource.deleteUser("1", REALM, false); + userResource.deleteUser("1", REALM, false); - assertEquals(204, response.getStatus()); verify(userService).deleteUser("1", REALM, false); } - @Test - void testDeleteUserHard() { - doNothing().when(userService).deleteUser("1", REALM, true); - - Response response = userResource.deleteUser("1", REALM, true); - - assertEquals(204, response.getStatus()); - verify(userService).deleteUser("1", REALM, true); - } - - @Test - void testDeleteUserError() { - doThrow(new RuntimeException("Delete failed")).when(userService).deleteUser("1", REALM, false); - - Response response = userResource.deleteUser("1", REALM, false); - - assertEquals(500, response.getStatus()); - } - @Test void testActivateUser() { doNothing().when(userService).activateUser("1", REALM); - Response response = userResource.activateUser("1", REALM); + userResource.activateUser("1", REALM); - assertEquals(204, response.getStatus()); verify(userService).activateUser("1", REALM); } - @Test - void testActivateUserError() { - doThrow(new RuntimeException("Activate failed")).when(userService).activateUser("1", REALM); - - Response response = userResource.activateUser("1", REALM); - - assertEquals(500, response.getStatus()); - } - @Test void testDeactivateUser() { doNothing().when(userService).deactivateUser("1", REALM, "reason"); - Response response = userResource.deactivateUser("1", REALM, "reason"); + userResource.deactivateUser("1", REALM, "reason"); - assertEquals(204, response.getStatus()); verify(userService).deactivateUser("1", REALM, "reason"); } - @Test - void testDeactivateUserError() { - doThrow(new RuntimeException("Deactivate failed")).when(userService).deactivateUser("1", REALM, null); - - Response response = userResource.deactivateUser("1", REALM, null); - - assertEquals(500, response.getStatus()); - } - @Test void testResetPassword() { doNothing().when(userService).resetPassword("1", REALM, "newpassword", true); - UserResource.PasswordResetRequest request = new UserResource.PasswordResetRequest(); - request.password = "newpassword"; - request.temporary = true; + PasswordResetRequestDTO request = PasswordResetRequestDTO.builder() + .password("newpassword") + .temporary(true) + .build(); - Response response = userResource.resetPassword("1", REALM, request); + userResource.resetPassword("1", REALM, request); - assertEquals(204, response.getStatus()); verify(userService).resetPassword("1", REALM, "newpassword", true); } - @Test - void testResetPasswordError() { - doThrow(new RuntimeException("Reset failed")).when(userService).resetPassword(any(), any(), any(), - anyBoolean()); - - UserResource.PasswordResetRequest request = new UserResource.PasswordResetRequest(); - request.password = "newpassword"; - - Response response = userResource.resetPassword("1", REALM, request); - - assertEquals(500, response.getStatus()); - } - @Test void testSendVerificationEmail() { doNothing().when(userService).sendVerificationEmail("1", REALM); - Response response = userResource.sendVerificationEmail("1", REALM); + userResource.sendVerificationEmail("1", REALM); - assertEquals(204, response.getStatus()); verify(userService).sendVerificationEmail("1", REALM); } - @Test - void testSendVerificationEmailError() { - doThrow(new RuntimeException("Email failed")).when(userService).sendVerificationEmail("1", REALM); - - Response response = userResource.sendVerificationEmail("1", REALM); - - assertEquals(500, response.getStatus()); - } - @Test void testLogoutAllSessions() { when(userService.logoutAllSessions("1", REALM)).thenReturn(5); - Response response = userResource.logoutAllSessions("1", REALM); + SessionsRevokedDTO result = userResource.logoutAllSessions("1", REALM); - assertEquals(200, response.getStatus()); - assertNotNull(response.getEntity()); - } - - @Test - void testLogoutAllSessionsError() { - when(userService.logoutAllSessions("1", REALM)).thenThrow(new RuntimeException("Logout failed")); - - Response response = userResource.logoutAllSessions("1", REALM); - - assertEquals(500, response.getStatus()); + assertNotNull(result); + assertEquals(5, result.getCount()); } @Test void testGetActiveSessions() { - when(userService.getActiveSessions("1", REALM)).thenReturn(Collections.emptyList()); + when(userService.getActiveSessions("1", REALM)).thenReturn(Collections.singletonList("session-1")); - Response response = userResource.getActiveSessions("1", REALM); + List result = userResource.getActiveSessions("1", REALM); - assertEquals(200, response.getStatus()); - } - - @Test - void testGetActiveSessionsError() { - when(userService.getActiveSessions("1", REALM)).thenThrow(new RuntimeException("Sessions failed")); - - Response response = userResource.getActiveSessions("1", REALM); - - assertEquals(500, response.getStatus()); - } - - @Test - void testPasswordResetRequestClass() { - UserResource.PasswordResetRequest request = new UserResource.PasswordResetRequest(); - request.password = "password123"; - request.temporary = false; - - assertEquals("password123", request.password); - assertFalse(request.temporary); - } - - @Test - void testSessionsRevokedResponseClass() { - UserResource.SessionsRevokedResponse response = new UserResource.SessionsRevokedResponse(5); - assertEquals(5, response.count); - } - - @Test - void testErrorResponseClass() { - UserResource.ErrorResponse response = new UserResource.ErrorResponse("Error message"); - assertEquals("Error message", response.message); + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("session-1", result.get(0)); } } diff --git a/src/test/java/dev/lions/user/manager/service/impl/AuditServiceImplTest.java b/src/test/java/dev/lions/user/manager/service/impl/AuditServiceImplTest.java index 75c0a90..e35bd39 100644 --- a/src/test/java/dev/lions/user/manager/service/impl/AuditServiceImplTest.java +++ b/src/test/java/dev/lions/user/manager/service/impl/AuditServiceImplTest.java @@ -2,21 +2,44 @@ package dev.lions.user.manager.service.impl; import dev.lions.user.manager.dto.audit.AuditLogDTO; import dev.lions.user.manager.enums.audit.TypeActionAudit; +import dev.lions.user.manager.server.impl.entity.AuditLogEntity; +import dev.lions.user.manager.server.impl.mapper.AuditLogMapper; +import dev.lions.user.manager.server.impl.repository.AuditLogRepository; +import io.quarkus.hibernate.orm.panache.PanacheQuery; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.time.LocalDateTime; -import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class AuditServiceImplTest { + @Mock + AuditLogRepository auditLogRepository; + + @Mock + AuditLogMapper auditLogMapper; + + @InjectMocks AuditServiceImpl auditService; @BeforeEach void setUp() { - auditService = new AuditServiceImpl(); - auditService.auditEnabled = true; // manually injecting config property - auditService.logToDatabase = false; + auditService.auditEnabled = true; + auditService.logToDatabase = true; } @Test @@ -25,11 +48,11 @@ class AuditServiceImplTest { log.setTypeAction(TypeActionAudit.USER_CREATE); log.setActeurUsername("admin"); + when(auditLogMapper.toEntity(any(AuditLogDTO.class))).thenReturn(new AuditLogEntity()); + auditService.logAction(log); - assertEquals(1, auditService.getTotalCount()); - assertNotNull(log.getId()); - assertNotNull(log.getDateAction()); + verify(auditLogRepository).persist(any(AuditLogEntity.class)); } @Test @@ -38,41 +61,58 @@ class AuditServiceImplTest { AuditLogDTO log = new AuditLogDTO(); auditService.logAction(log); - assertEquals(0, auditService.getTotalCount()); + + verify(auditLogRepository, never()).persist(any(AuditLogEntity.class)); } @Test void testLogSuccess() { + when(auditLogMapper.toEntity(any(AuditLogDTO.class))).thenReturn(new AuditLogEntity()); + auditService.logSuccess(TypeActionAudit.USER_CREATE, "USER", "1", "user", "realm", "admin", "desc"); - assertEquals(1, auditService.getTotalCount()); + + verify(auditLogRepository).persist(any(AuditLogEntity.class)); } @Test void testLogFailure() { + when(auditLogMapper.toEntity(any(AuditLogDTO.class))).thenReturn(new AuditLogEntity()); + auditService.logFailure(TypeActionAudit.USER_CREATE, "USER", "1", "user", "realm", "admin", "ERR", "Error"); - assertEquals(1, auditService.getTotalCount()); + + verify(auditLogRepository).persist(any(AuditLogEntity.class)); + + // Test findFailures mock logic + when(auditLogRepository.search(anyString(), any(), any(), any(), any(), eq(false), anyInt(), anyInt())) + .thenReturn(Collections.singletonList(new AuditLogEntity())); + when(auditLogMapper.toDTOList(anyList())).thenReturn(Collections.singletonList(new AuditLogDTO())); + List failures = auditService.findFailures("realm", null, null, 0, 10); assertEquals(1, failures.size()); } @Test void testSearchLogs() { - auditService.logSuccess(TypeActionAudit.USER_CREATE, "USER", "1", "user1", "realm", "admin1", ""); - auditService.logSuccess(TypeActionAudit.USER_UPDATE, "USER", "1", "user1", "realm", "admin1", ""); - auditService.logSuccess(TypeActionAudit.ROLE_CREATE, "ROLE", "r", "role", "realm", "admin2", ""); + // Mocking repo results + when(auditLogRepository.search(any(), anyString(), any(), any(), any(), any(), anyInt(), anyInt())) + .thenReturn(Collections.singletonList(new AuditLogEntity())); + when(auditLogMapper.toDTOList(anyList())).thenReturn(Collections.singletonList(new AuditLogDTO())); List byActeur = auditService.findByActeur("admin1", null, null, 0, 10); - assertEquals(2, byActeur.size()); + assertNotNull(byActeur); + assertFalse(byActeur.isEmpty()); + + when(auditLogRepository.search(anyString(), any(), any(), any(), anyString(), any(), anyInt(), anyInt())) + .thenReturn(Collections.singletonList(new AuditLogEntity())); List byType = auditService.findByTypeAction(TypeActionAudit.ROLE_CREATE, "realm", null, null, 0, 10); - assertEquals(1, byType.size()); + assertNotNull(byType); } @Test void testClearAll() { - auditService.logAction(new AuditLogDTO()); auditService.clearAll(); - assertEquals(0, auditService.getTotalCount()); + verify(auditLogRepository).deleteAll(); } } diff --git a/src/test/java/dev/lions/user/manager/service/impl/SyncServiceImplTest.java b/src/test/java/dev/lions/user/manager/service/impl/SyncServiceImplTest.java index df65d08..5345cef 100644 --- a/src/test/java/dev/lions/user/manager/service/impl/SyncServiceImplTest.java +++ b/src/test/java/dev/lions/user/manager/service/impl/SyncServiceImplTest.java @@ -21,7 +21,11 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import org.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; + @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SyncServiceImplTest { @Mock @@ -45,6 +49,15 @@ class SyncServiceImplTest { @Mock ServerInfoResource serverInfoResource; + @Mock + dev.lions.user.manager.server.impl.repository.SyncHistoryRepository syncHistoryRepository; + + @Mock + dev.lions.user.manager.server.impl.repository.SyncedUserRepository syncedUserRepository; + + @Mock + dev.lions.user.manager.server.impl.repository.SyncedRoleRepository syncedRoleRepository; + @InjectMocks SyncServiceImpl syncService; @@ -116,9 +129,6 @@ class SyncServiceImplTest { when(serverInfoResource.getInfo()).thenReturn(info); - when(keycloakInstance.realms()).thenReturn(realmsResource); - when(realmsResource.findAll()).thenReturn(Collections.emptyList()); - Map health = syncService.getKeycloakHealthInfo(); assertTrue((Boolean) health.get("overallHealthy")); assertEquals("1.0", health.get("keycloakVersion")); @@ -173,7 +183,8 @@ class SyncServiceImplTest { assertTrue(result.isEmpty()); } - // Note: checkDataConsistency doesn't actually throw exceptions in the current implementation + // Note: checkDataConsistency doesn't actually throw exceptions in the current + // implementation // The try-catch block is there for future use, but currently always succeeds // So we test the success path in testCheckDataConsistency_Success @@ -211,32 +222,22 @@ class SyncServiceImplTest { assertNotNull(health.get("errorMessage")); } - @Test - void testGetKeycloakHealthInfo_RealmsException() { - when(keycloakAdminClient.getInstance()).thenReturn(keycloakInstance); - when(keycloakInstance.serverInfo()).thenReturn(serverInfoResource); - - ServerInfoRepresentation info = new ServerInfoRepresentation(); - SystemInfoRepresentation systemInfo = new SystemInfoRepresentation(); - systemInfo.setVersion("1.0"); - info.setSystemInfo(systemInfo); - - when(serverInfoResource.getInfo()).thenReturn(info); - - when(keycloakInstance.realms()).thenReturn(realmsResource); - when(realmsResource.findAll()).thenThrow(new RuntimeException("Realms error")); - - Map health = syncService.getKeycloakHealthInfo(); - assertTrue((Boolean) health.get("overallHealthy")); // Still healthy if server is accessible - assertFalse((Boolean) health.get("realmsAccessible")); - } - @Test void testCheckDataConsistency_Success() { + when(keycloakAdminClient.getInstance()).thenReturn(keycloakInstance); + when(keycloakInstance.realm("realm")).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.list()).thenReturn(Collections.emptyList()); + when(realmResource.roles()).thenReturn(rolesResource); + when(rolesResource.list()).thenReturn(Collections.emptyList()); + + when(syncedUserRepository.list(anyString(), anyString())).thenReturn(Collections.emptyList()); + when(syncedRoleRepository.list(anyString(), anyString())).thenReturn(Collections.emptyList()); + Map report = syncService.checkDataConsistency("realm"); + assertEquals("realm", report.get("realmName")); - assertEquals("ok", report.get("status")); - assertEquals("Cohérence vérifiée", report.get("message")); + assertEquals("OK", report.get("status")); } @Test diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 33bbacf..30ad025 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -8,6 +8,15 @@ lions.keycloak.admin-password=admin lions.keycloak.connection-pool-size=10 lions.keycloak.timeout-seconds=30 +# Quarkus-managed Keycloak Admin Client (tests) +quarkus.keycloak.admin-client.server-url=${lions.keycloak.server-url} +quarkus.keycloak.admin-client.realm=${lions.keycloak.admin-realm} +quarkus.keycloak.admin-client.client-id=${lions.keycloak.admin-client-id} +quarkus.keycloak.admin-client.username=${lions.keycloak.admin-username} +quarkus.keycloak.admin-client.password=${lions.keycloak.admin-password} +quarkus.keycloak.admin-client.grant-type=PASSWORD +quarkus.keycloak.admin-client.enabled=false + # Keycloak OIDC Configuration (désactivé pour les tests) quarkus.oidc.tenant-enabled=false quarkus.keycloak.policy-enforcer.enable=false