From 40a2dd9728e3aebf6b6b75ef28f996e161445c94 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:51:14 +0000 Subject: [PATCH] Refactoring - Version stable --- .../server/resource/MembreResource.java | 27 ++++ .../service/MembreKeycloakSyncService.java | 56 +++++++ .../server/service/MembreService.java | 27 ++++ .../MembreKeycloakSyncServiceTest.java | 142 ++++++++++++++++++ .../server/service/MembreServiceTest.java | 44 ++++++ 5 files changed, 296 insertions(+) diff --git a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index 092ba2d..bbe83b4 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -674,6 +674,33 @@ public class MembreResource { .build(); } + @PUT + @Path("/{id}/promouvoir-admin-organisation") + @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) + @Operation( + summary = "Promouvoir un membre en administrateur d'organisation", + description = "Passe le membre en statut ACTIF et lui assigne le rôle ADMIN_ORGANISATION dans Keycloak. " + + "Réservé aux super administrateurs de la plateforme. " + + "Le compte est immédiatement opérationnel sans validation intermédiaire.") + @APIResponse(responseCode = "200", description = "Membre promu administrateur d'organisation") + @APIResponse(responseCode = "404", description = "Membre non trouvé") + @APIResponse(responseCode = "403", description = "Accès réservé aux ADMIN / SUPER_ADMIN") + public Response promouvoirAdminOrganisation( + @Parameter(description = "UUID du membre à promouvoir") @PathParam("id") UUID id) { + LOG.infof("Promotion admin d'organisation pour le membre ID: %s", id); + + Membre membrePromu = membreService.promouvoirAdminOrganisation(id); + + // Assigner ADMIN_ORGANISATION dans Keycloak (non bloquant) + try { + keycloakSyncService.promouvoirAdminOrganisationDansKeycloak(id); + } catch (Exception e) { + LOG.warnf("Promotion Keycloak échouée pour %s (non bloquant): %s", membrePromu.getEmail(), e.getMessage()); + } + + return Response.ok(membreService.convertToResponse(membrePromu)).build(); + } + @PUT @Path("/{id}/activer") @RolesAllowed({"ADMIN", "SUPER_ADMIN"}) diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java index f117f25..190b48b 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreKeycloakSyncService.java @@ -196,6 +196,62 @@ public class MembreKeycloakSyncService { } } + /** + * Promeut un membre au rôle ADMIN_ORGANISATION dans Keycloak. + * Appelé après MembreService.promouvoirAdminOrganisation(). + * + *

Si le membre n'a pas encore de compte Keycloak, le provisionne d'abord. + * Assigne ensuite ADMIN_ORGANISATION et retire les rôles MEMBRE / MEMBRE_ACTIF + * (un admin gère l'organisation — il n'est pas un membre ordinaire). + * + * @param membreId UUID du membre à promouvoir dans Keycloak + * @throws NotFoundException si le membre n'existe pas en base + */ + @Transactional + public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) { + LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId); + + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId)); + + // Provisionner le compte Keycloak s'il n'existe pas encore + if (membre.getKeycloakId() == null) { + LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet()); + provisionKeycloakUser(membreId); + membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId)); + } + + String keycloakUserId = membre.getKeycloakId().toString(); + + try { + UserDTO user = userServiceClient.getUserById(keycloakUserId, DEFAULT_REALM); + + // S'assurer que le compte est activé + if (Boolean.FALSE.equals(user.getEnabled())) { + user.setEnabled(true); + } + + // Construire la liste de rôles : ajouter ADMIN_ORGANISATION, retirer MEMBRE et MEMBRE_ACTIF + List roles = user.getRealmRoles() != null + ? new java.util.ArrayList<>(user.getRealmRoles()) + : new java.util.ArrayList<>(); + roles.remove("MEMBRE"); + roles.remove("MEMBRE_ACTIF"); + if (!roles.contains("ADMIN_ORGANISATION")) { + roles.add("ADMIN_ORGANISATION"); + } + user.setRealmRoles(roles); + userServiceClient.updateUser(keycloakUserId, user, DEFAULT_REALM); + + LOGGER.info("✅ Rôle ADMIN_ORGANISATION assigné dans Keycloak pour " + membre.getNomComplet()); + + } catch (Exception e) { + LOGGER.severe("❌ Erreur promotion Keycloak pour " + membre.getNomComplet() + ": " + e.getMessage()); + throw new RuntimeException("Impossible de promouvoir le compte Keycloak: " + e.getMessage(), e); + } + } + /** * Synchronise les données du Membre vers le User Keycloak. * Si le membre n'a pas de compte Keycloak, le provisionne automatiquement. diff --git a/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/src/main/java/dev/lions/unionflow/server/service/MembreService.java index 58da914..6194971 100644 --- a/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -118,6 +118,33 @@ public class MembreService { return membre; } + /** + * Promeut un membre au rôle d'administrateur d'organisation. + * Passe immédiatement le statut à ACTIF — les admins sont opérationnels sans + * validation intermédiaire. + * Doit être suivi d'un appel à + * MembreKeycloakSyncService.promouvoirAdminOrganisationDansKeycloak() + * pour que le rôle ADMIN_ORGANISATION soit assigné dans Keycloak. + * + * @param membreId UUID du membre à promouvoir + * @return Le membre mis à jour + * @throws jakarta.ws.rs.NotFoundException si le membre est introuvable + */ + @Transactional + public Membre promouvoirAdminOrganisation(UUID membreId) { + LOG.infof("Promotion admin d'organisation pour le membre ID: %s", membreId); + + Membre membre = membreRepository.findByIdOptional(membreId) + .orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé avec l'ID: " + membreId)); + + membre.setStatutCompte("ACTIF"); + membre.setActif(true); + membreRepository.persist(membre); + + LOG.infof("Membre promu admin d'organisation: %s (ID: %s)", membre.getNomComplet(), membreId); + return membre; + } + /** Met à jour un membre existant */ @Transactional public Membre mettreAJourMembre(UUID id, Membre membreModifie) { diff --git a/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java index 3be78c6..869d38c 100644 --- a/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/MembreKeycloakSyncServiceTest.java @@ -217,6 +217,148 @@ class MembreKeycloakSyncServiceTest { verify(membreRepository).persist(membre); } + // ========================================================================= + // promouvoirAdminOrganisationDansKeycloak + // ========================================================================= + + @Test + @DisplayName("promouvoirAdminOrganisation échoue si le membre n'existe pas") + void promouvoirAdminOrganisation_failsIfMembreNotFound() { + UUID membreId = UUID.randomUUID(); + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> syncService.promouvoirAdminOrganisationDansKeycloak(membreId)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("promouvoirAdminOrganisation assigne ADMIN_ORGANISATION et retire MEMBRE/MEMBRE_ACTIF") + void promouvoirAdminOrganisation_assignsAdminRoleAndRemovesMemberRoles() { + UUID membreId = UUID.randomUUID(); + UUID keycloakId = UUID.randomUUID(); + + Membre membre = new Membre(); + membre.setId(membreId); + membre.setKeycloakId(keycloakId); + membre.setEmail("admin@unionflow.dev"); + membre.setNom("Admin"); + membre.setPrenom("New"); + + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre)); + + UserDTO user = new UserDTO(); + user.setId(keycloakId.toString()); + user.setEnabled(true); + user.setRealmRoles(new java.util.ArrayList<>(java.util.List.of("MEMBRE", "MEMBRE_ACTIF"))); + user.setRealmName("unionflow"); + when(userServiceClient.getUserById(eq(keycloakId.toString()), eq("unionflow"))).thenReturn(user); + when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(user); + + syncService.promouvoirAdminOrganisationDansKeycloak(membreId); + + verify(userServiceClient).updateUser(eq(keycloakId.toString()), any(UserDTO.class), eq("unionflow")); + assertThat(user.getRealmRoles()).contains("ADMIN_ORGANISATION"); + assertThat(user.getRealmRoles()).doesNotContain("MEMBRE", "MEMBRE_ACTIF"); + } + + @Test + @DisplayName("promouvoirAdminOrganisation active le compte s'il est désactivé dans Keycloak") + void promouvoirAdminOrganisation_enablesDisabledAccount() { + UUID membreId = UUID.randomUUID(); + UUID keycloakId = UUID.randomUUID(); + + Membre membre = new Membre(); + membre.setId(membreId); + membre.setKeycloakId(keycloakId); + membre.setEmail("disabled-admin@unionflow.dev"); + membre.setNom("Disabled"); + membre.setPrenom("Admin"); + + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre)); + + UserDTO user = new UserDTO(); + user.setId(keycloakId.toString()); + user.setEnabled(false); // désactivé + user.setRealmRoles(new java.util.ArrayList<>()); + user.setRealmName("unionflow"); + when(userServiceClient.getUserById(anyString(), anyString())).thenReturn(user); + when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(user); + + syncService.promouvoirAdminOrganisationDansKeycloak(membreId); + + assertThat(user.getEnabled()).isTrue(); + verify(userServiceClient).updateUser(anyString(), any(UserDTO.class), anyString()); + } + + @Test + @DisplayName("promouvoirAdminOrganisation provisionne Keycloak si keycloakId est null") + void promouvoirAdminOrganisation_provisionesIfNoKeycloakAccount() { + UUID membreId = UUID.randomUUID(); + + Membre membre = new Membre(); + membre.setId(membreId); + membre.setEmail("no-kc-admin@unionflow.dev"); + membre.setNom("No"); + membre.setPrenom("KC"); + // keycloakId == null + + UUID newKeycloakId = UUID.randomUUID(); + Membre membreWithKc = new Membre(); + membreWithKc.setId(membreId); + membreWithKc.setEmail("no-kc-admin@unionflow.dev"); + membreWithKc.setNom("No"); + membreWithKc.setPrenom("KC"); + membreWithKc.setKeycloakId(newKeycloakId); + + when(membreRepository.findByIdOptional(membreId)) + .thenReturn(Optional.of(membre)) // 1er appel (promouvoir) : pas de keycloakId → déclenche provisionnement + .thenReturn(Optional.of(membre)) // 2e appel (provisionKeycloakUser interne) : pas de keycloakId + .thenReturn(Optional.of(membreWithKc)); // 3e appel : rechargement après provisionnement + + UserSearchResultDTO searchResult = new UserSearchResultDTO(); + searchResult.setUsers(java.util.Collections.emptyList()); + when(userServiceClient.searchUsers(any())).thenReturn(searchResult); + + UserDTO createdUser = new UserDTO(); + createdUser.setId(newKeycloakId.toString()); + when(userServiceClient.createUser(any(UserDTO.class), anyString())).thenReturn(createdUser); + + UserDTO fetchedUser = new UserDTO(); + fetchedUser.setId(newKeycloakId.toString()); + fetchedUser.setEnabled(true); + fetchedUser.setRealmRoles(new java.util.ArrayList<>()); + fetchedUser.setRealmName("unionflow"); + when(userServiceClient.getUserById(eq(newKeycloakId.toString()), anyString())).thenReturn(fetchedUser); + when(userServiceClient.updateUser(anyString(), any(UserDTO.class), anyString())).thenReturn(fetchedUser); + + syncService.promouvoirAdminOrganisationDansKeycloak(membreId); + + verify(userServiceClient).createUser(any(UserDTO.class), eq("unionflow")); + verify(userServiceClient).updateUser(eq(newKeycloakId.toString()), any(UserDTO.class), eq("unionflow")); + } + + @Test + @DisplayName("promouvoirAdminOrganisation lève RuntimeException si l'appel Keycloak échoue") + void promouvoirAdminOrganisation_throwsRuntimeExceptionOnKeycloakFailure() { + UUID membreId = UUID.randomUUID(); + UUID keycloakId = UUID.randomUUID(); + + Membre membre = new Membre(); + membre.setId(membreId); + membre.setKeycloakId(keycloakId); + membre.setEmail("fail-admin@unionflow.dev"); + membre.setNom("Fail"); + membre.setPrenom("Admin"); + + when(membreRepository.findByIdOptional(membreId)).thenReturn(Optional.of(membre)); + when(userServiceClient.getUserById(anyString(), anyString())) + .thenThrow(new RuntimeException("Keycloak unreachable")); + + assertThatThrownBy(() -> syncService.promouvoirAdminOrganisationDansKeycloak(membreId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Impossible de promouvoir le compte Keycloak"); + } + // ========================================================================= // syncMembreToKeycloak // ========================================================================= diff --git a/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java index 2034d78..edc0762 100644 --- a/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java +++ b/src/test/java/dev/lions/unionflow/server/service/MembreServiceTest.java @@ -246,6 +246,50 @@ class MembreServiceTest { } } + // ========================================================================= + // promouvoirAdminOrganisation + // ========================================================================= + + @Nested + @DisplayName("promouvoirAdminOrganisation") + class PromouvoirAdminOrganisationTests { + + @Test + @DisplayName("Happy path: membre promu → ACTIF + actif=true immédiatement") + void promouvoirAdminOrganisation_setsActifImmediately() { + UUID id = UUID.randomUUID(); + Membre membre = new Membre(); + membre.setId(id); + membre.setEmail("future-admin@unionflow.dev"); + membre.setNom("Admin"); + membre.setPrenom("New"); + membre.setStatutCompte("EN_ATTENTE_VALIDATION"); + membre.setActif(false); + + when(membreRepository.findByIdOptional(id)).thenReturn(Optional.of(membre)); + doNothing().when(membreRepository).persist(any(Membre.class)); + + Membre result = membreService.promouvoirAdminOrganisation(id); + + assertThat(result.getStatutCompte()).isEqualTo("ACTIF"); + assertThat(result.getActif()).isTrue(); + verify(membreRepository).persist(membre); + } + + @Test + @DisplayName("Membre introuvable: lève NotFoundException") + void promouvoirAdminOrganisation_notFound_throws() { + UUID id = UUID.randomUUID(); + when(membreRepository.findByIdOptional(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> membreService.promouvoirAdminOrganisation(id)) + .isInstanceOf(jakarta.ws.rs.NotFoundException.class) + .hasMessageContaining("non trouvé"); + + verify(membreRepository, never()).persist(any(Membre.class)); + } + } + // ========================================================================= // mettreAJourMembre // =========================================================================