diff --git a/pom.xml b/pom.xml index 94de1d4..2712c0e 100644 --- a/pom.xml +++ b/pom.xml @@ -69,7 +69,12 @@ - + + + com.powsybl + powsybl-ws-commons + 1.30.0 + org.gridsuite diff --git a/src/main/java/org/gridsuite/useradmin/server/PropertyServerNameProvider.java b/src/main/java/org/gridsuite/useradmin/server/PropertyServerNameProvider.java new file mode 100644 index 0000000..987ddb7 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/PropertyServerNameProvider.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server; + +import com.powsybl.ws.commons.error.ServerNameProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +@Component +public class PropertyServerNameProvider implements ServerNameProvider { + + private final String name; + + public PropertyServerNameProvider(@Value("${spring.application.name:user-admin-server}") String name) { + this.name = name; + } + + @Override + public String serverName() { + return name; + } +} diff --git a/src/main/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandler.java b/src/main/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandler.java deleted file mode 100644 index a0cf64d..0000000 --- a/src/main/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandler.java +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Copyright (c) 2022, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -package org.gridsuite.useradmin.server; - -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; -import org.jetbrains.annotations.NotNull; -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; - -import java.net.URI; -import java.time.Instant; -import java.util.stream.Collectors; - -/** - * Handle exceptions catch from the {@link org.gridsuite.useradmin.server.controller controllers}. - */ -@RestControllerAdvice -public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { - @ExceptionHandler({UserAdminException.class}) - protected ResponseEntity handleException(@NotNull final UserAdminException userAdminException) { - return switch (userAdminException.getType()) { - case FORBIDDEN -> ResponseEntity.status(HttpStatus.FORBIDDEN).body(userAdminException.getType()); - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(userAdminException.getType()); - case GROUP_ALREADY_EXISTS, USER_ALREADY_EXISTS, PROFILE_ALREADY_EXISTS, SEVERITY_DOES_NOT_EXIST, - OVERLAPPING_ANNOUNCEMENTS, START_DATE_SAME_OR_AFTER_END_DATE -> - ResponseEntity.status(HttpStatus.BAD_REQUEST).body(userAdminException.getType()); - }; - } - - /** - * {@link org.springframework.validation.annotation.Validated @Validated} errors handler. - */ - @ExceptionHandler({ConstraintViolationException.class}) // @Validated errors - public ProblemDetail handleConstraintViolation(@NotNull final ConstraintViolationException exception, WebRequest request) { - final ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Entity validation error"); - problemDetail.setType(URI.create("http://gridsuite.org/errors/constraint-violation")); - problemDetail.setProperty("violations", exception.getConstraintViolations().stream().collect(Collectors.toUnmodifiableMap(ConstraintViolation::getPropertyPath, ConstraintViolation::getMessage))); - problemDetail.setProperty("timestamp", Instant.now()); - return problemDetail; - } -} diff --git a/src/main/java/org/gridsuite/useradmin/server/UserAdminException.java b/src/main/java/org/gridsuite/useradmin/server/UserAdminException.java deleted file mode 100644 index 114dd8d..0000000 --- a/src/main/java/org/gridsuite/useradmin/server/UserAdminException.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2022, RTE (http://www.rte-france.com) - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ -package org.gridsuite.useradmin.server; - -import java.util.Objects; - -/** - * @author Etienne Homer - */ -public class UserAdminException extends RuntimeException { - public enum Type { - FORBIDDEN, - NOT_FOUND, - GROUP_ALREADY_EXISTS, - USER_ALREADY_EXISTS, - PROFILE_ALREADY_EXISTS, - SEVERITY_DOES_NOT_EXIST, - OVERLAPPING_ANNOUNCEMENTS, - START_DATE_SAME_OR_AFTER_END_DATE - } - - private final Type type; - - public UserAdminException(Type type) { - super(Objects.requireNonNull(type.name())); - this.type = type; - } - - Type getType() { - return type; - } -} diff --git a/src/main/java/org/gridsuite/useradmin/server/controller/AnnouncementController.java b/src/main/java/org/gridsuite/useradmin/server/controller/AnnouncementController.java index 06ab843..b2a494d 100644 --- a/src/main/java/org/gridsuite/useradmin/server/controller/AnnouncementController.java +++ b/src/main/java/org/gridsuite/useradmin/server/controller/AnnouncementController.java @@ -9,7 +9,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Future; import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import org.gridsuite.useradmin.server.UserAdminApi; @@ -56,7 +55,7 @@ public ResponseEntity getCurrentAnnouncement() { @ApiResponse(responseCode = "403", description = "User is not an admin") @ApiResponse(responseCode = "409", description = "There is a conflict in display time") public ResponseEntity createAnnouncement(@RequestParam("startDate") Instant startDate, - @RequestParam("endDate") @Future Instant endDate, + @RequestParam("endDate") Instant endDate, @RequestParam("severity") AnnouncementSeverity severity, @RequestBody @NotBlank String message) { return ResponseEntity.ok(service.createAnnouncement(startDate, endDate, message, severity)); diff --git a/src/main/java/org/gridsuite/useradmin/server/error/RestResponseEntityExceptionHandler.java b/src/main/java/org/gridsuite/useradmin/server/error/RestResponseEntityExceptionHandler.java new file mode 100644 index 0000000..c4ebdff --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/error/RestResponseEntityExceptionHandler.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server.error; + +import com.powsybl.ws.commons.error.AbstractBaseRestExceptionHandler; +import com.powsybl.ws.commons.error.ServerNameProvider; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; + +/** + * @author Mohamed Ben-rejeb {@literal } + * + * Handle exception catch from the {@link org.gridsuite.useradmin.server.controller controllers}. + */ +@ControllerAdvice +public class RestResponseEntityExceptionHandler + extends AbstractBaseRestExceptionHandler { + + public RestResponseEntityExceptionHandler(ServerNameProvider serverNameProvider) { + super(serverNameProvider); + } + + @NotNull + @Override + protected UserAdminBusinessErrorCode getBusinessCode(UserAdminException ex) { + return ex.getBusinessErrorCode(); + } + + @Override + protected HttpStatus mapStatus(UserAdminBusinessErrorCode errorCode) { + return switch (errorCode) { + case USER_ADMIN_PERMISSION_DENIED -> HttpStatus.FORBIDDEN; + case USER_ADMIN_USER_NOT_FOUND, + USER_ADMIN_PROFILE_NOT_FOUND, + USER_ADMIN_GROUP_NOT_FOUND -> HttpStatus.NOT_FOUND; + case USER_ADMIN_USER_ALREADY_EXISTS, + USER_ADMIN_PROFILE_ALREADY_EXISTS, + USER_ADMIN_GROUP_ALREADY_EXISTS, + USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD, + USER_ADMIN_ANNOUNCEMENT_OVERLAP -> HttpStatus.BAD_REQUEST; + }; + } +} diff --git a/src/main/java/org/gridsuite/useradmin/server/error/UserAdminBusinessErrorCode.java b/src/main/java/org/gridsuite/useradmin/server/error/UserAdminBusinessErrorCode.java new file mode 100644 index 0000000..39ab3f5 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/error/UserAdminBusinessErrorCode.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server.error; + +import com.powsybl.ws.commons.error.BusinessErrorCode; + +/** + * @author Mohamed Ben-rejeb {@literal } + * + * Business error codes exposed by the user-admin server. + */ +public enum UserAdminBusinessErrorCode implements BusinessErrorCode { + USER_ADMIN_PERMISSION_DENIED("useradmin.permissionDenied"), + USER_ADMIN_USER_NOT_FOUND("useradmin.userNotFound"), + USER_ADMIN_USER_ALREADY_EXISTS("useradmin.userAlreadyExists"), + USER_ADMIN_PROFILE_NOT_FOUND("useradmin.profileNotFound"), + USER_ADMIN_PROFILE_ALREADY_EXISTS("useradmin.profileAlreadyExists"), + USER_ADMIN_GROUP_NOT_FOUND("useradmin.groupNotFound"), + USER_ADMIN_GROUP_ALREADY_EXISTS("useradmin.groupAlreadyExists"), + USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD("useradmin.announcementInvalidPeriod"), + USER_ADMIN_ANNOUNCEMENT_OVERLAP("useradmin.announcementOverlap"); + + private final String value; + + UserAdminBusinessErrorCode(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/src/main/java/org/gridsuite/useradmin/server/error/UserAdminException.java b/src/main/java/org/gridsuite/useradmin/server/error/UserAdminException.java new file mode 100644 index 0000000..6b4b613 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/error/UserAdminException.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server.error; + +import com.powsybl.ws.commons.error.AbstractBusinessException; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +import static org.gridsuite.useradmin.server.error.UserAdminBusinessErrorCode.*; + +/** + * @author Mohamed Ben-rejeb {@literal } + * + * User admin specific runtime exception enriched with a business error code. + */ +public class UserAdminException extends AbstractBusinessException { + + private final UserAdminBusinessErrorCode errorCode; + + public UserAdminException(UserAdminBusinessErrorCode errorCode, String message) { + super(Objects.requireNonNull(message, "message must not be null")); + this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null"); + } + + public static UserAdminException forbidden() { + return new UserAdminException(USER_ADMIN_PERMISSION_DENIED, "User is not allowed to perform this action"); + } + + public static UserAdminException userAlreadyExists(String sub) { + return new UserAdminException(USER_ADMIN_USER_ALREADY_EXISTS, String.format("User '%s' already exists", sub)); + } + + public static UserAdminException userNotFound(String sub) { + return new UserAdminException(USER_ADMIN_USER_NOT_FOUND, String.format("User '%s' was not found", sub)); + } + + public static UserAdminException profileAlreadyExists(String name) { + return new UserAdminException(USER_ADMIN_PROFILE_ALREADY_EXISTS, String.format("Profile '%s' already exists", name)); + } + + public static UserAdminException profileNotFound(UUID profileId) { + return new UserAdminException(USER_ADMIN_PROFILE_NOT_FOUND, String.format("Profile '%s' was not found", profileId)); + } + + public static UserAdminException groupAlreadyExists(String name) { + return new UserAdminException(USER_ADMIN_GROUP_ALREADY_EXISTS, String.format("Group '%s' already exists", name)); + } + + public static UserAdminException groupNotFound(UUID groupId) { + return new UserAdminException(USER_ADMIN_GROUP_NOT_FOUND, String.format("Group '%s' was not found", groupId)); + } + + public static UserAdminException groupNotFound(String name) { + return new UserAdminException(USER_ADMIN_GROUP_NOT_FOUND, String.format("Group '%s' was not found", name)); + } + + public static UserAdminException announcementInvalidPeriod(Instant startDate, Instant endDate) { + return new UserAdminException(USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD, + String.format("Announcement end date '%s' must be after start date '%s'", endDate, startDate)); + } + + public static UserAdminException announcementOverlap(Instant startDate, Instant endDate) { + return new UserAdminException(USER_ADMIN_ANNOUNCEMENT_OVERLAP, + String.format("Announcement period [%s, %s] overlaps with an existing announcement", startDate, endDate)); + } + + public static UserAdminException of(UserAdminBusinessErrorCode errorCode, String message, Object... args) { + return new UserAdminException(errorCode, args.length == 0 ? message : String.format(message, args)); + } + + @NotNull + @Override + public UserAdminBusinessErrorCode getBusinessErrorCode() { + return errorCode; + } + +} diff --git a/src/main/java/org/gridsuite/useradmin/server/service/AdminRightService.java b/src/main/java/org/gridsuite/useradmin/server/service/AdminRightService.java index 57af863..f7a9b69 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/AdminRightService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/AdminRightService.java @@ -7,7 +7,7 @@ package org.gridsuite.useradmin.server.service; import org.gridsuite.useradmin.server.UserAdminApplicationProps; -import org.gridsuite.useradmin.server.UserAdminException; +import org.gridsuite.useradmin.server.error.UserAdminException; import org.springframework.stereotype.Service; import java.util.Objects; @@ -31,7 +31,7 @@ public void assertIsAdmin() throws UserAdminException { Set userRoles = getCurrentUserRoles(); if (userRoles.isEmpty() || !userRoles.contains(userAdminApplicationProps.getAdminRole())) { - throw new UserAdminException(UserAdminException.Type.FORBIDDEN); + throw UserAdminException.forbidden(); } } } diff --git a/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java b/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java index 470e3ee..874e2c8 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java @@ -7,7 +7,7 @@ package org.gridsuite.useradmin.server.service; import lombok.AllArgsConstructor; -import org.gridsuite.useradmin.server.UserAdminException; +import org.gridsuite.useradmin.server.error.UserAdminException; import org.gridsuite.useradmin.server.dto.Announcement; import org.gridsuite.useradmin.server.entity.AnnouncementEntity; import org.gridsuite.useradmin.server.entity.AnnouncementSeverity; @@ -19,8 +19,6 @@ import java.util.Optional; import java.util.UUID; -import static org.gridsuite.useradmin.server.UserAdminException.Type.*; - /** * @author Abdelsalem Hedhili */ @@ -37,11 +35,11 @@ public Announcement createAnnouncement(Instant startDate, AnnouncementSeverity severity) { adminRightService.assertIsAdmin(); if (!startDate.isBefore(endDate)) { // internally compare in seconds - throw new UserAdminException(START_DATE_SAME_OR_AFTER_END_DATE); + throw UserAdminException.announcementInvalidPeriod(startDate, endDate); } // Start is inclusive, End is exclusive — [start, end) if (announcementRepository.existsByStartDateLessThanAndEndDateGreaterThan(endDate, startDate)) { - throw new UserAdminException(OVERLAPPING_ANNOUNCEMENTS); + throw UserAdminException.announcementOverlap(startDate, endDate); } return announcementRepository.save(new AnnouncementEntity(startDate, endDate, message.trim(), severity)).toDto(); } diff --git a/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java b/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java index 002a421..cd9e499 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java @@ -7,7 +7,7 @@ package org.gridsuite.useradmin.server.service; import org.gridsuite.useradmin.server.UserAdminApplicationProps; -import org.gridsuite.useradmin.server.UserAdminException; +import org.gridsuite.useradmin.server.error.UserAdminException; import org.gridsuite.useradmin.server.dto.UserConnection; import org.gridsuite.useradmin.server.dto.UserGroup; import org.gridsuite.useradmin.server.dto.UserInfos; @@ -23,9 +23,6 @@ import java.util.*; import java.util.stream.Collectors; -import static org.gridsuite.useradmin.server.UserAdminException.Type.NOT_FOUND; -import static org.gridsuite.useradmin.server.UserAdminException.Type.USER_ALREADY_EXISTS; - /** * @author Etienne Homer */ @@ -79,7 +76,7 @@ public List getConnections() { public void createUser(String sub) { adminRightService.assertIsAdmin(); if (userInfosRepository.existsBySub(sub)) { - throw new UserAdminException(USER_ALREADY_EXISTS); + throw UserAdminException.userAlreadyExists(sub); } userInfosRepository.save(new UserInfosEntity(sub)); } @@ -93,7 +90,7 @@ private static void removeUserFromGroups(UserInfosEntity entity) { @Transactional public long delete(String sub) { adminRightService.assertIsAdmin(); - UserInfosEntity userInfosEntity = userInfosRepository.findBySub(sub).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + UserInfosEntity userInfosEntity = userInfosRepository.findBySub(sub).orElseThrow(() -> UserAdminException.userNotFound(sub)); removeUserFromGroups(userInfosEntity); return userInfosRepository.deleteBySub(sub); } @@ -102,7 +99,7 @@ public long delete(String sub) { public long delete(Collection subs) { adminRightService.assertIsAdmin(); subs.forEach(sub -> { - UserInfosEntity userInfosEntity = userInfosRepository.findBySub(sub).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + UserInfosEntity userInfosEntity = userInfosRepository.findBySub(sub).orElseThrow(() -> UserAdminException.userNotFound(sub)); removeUserFromGroups(userInfosEntity); }); return userInfosRepository.deleteAllBySubIn(subs); @@ -111,7 +108,7 @@ public long delete(Collection subs) { @Transactional() public void updateUser(String sub, UserInfos userInfos) { adminRightService.assertIsAdmin(); - UserInfosEntity user = userInfosRepository.findBySub(sub).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + UserInfosEntity user = userInfosRepository.findBySub(sub).orElseThrow(() -> UserAdminException.userNotFound(sub)); Optional profile = userProfileRepository.findByName(userInfos.profileName()); user.setSub(userInfos.sub()); user.setProfile(profile.orElse(null)); diff --git a/src/main/java/org/gridsuite/useradmin/server/service/UserGroupService.java b/src/main/java/org/gridsuite/useradmin/server/service/UserGroupService.java index 2514450..36aa1e6 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/UserGroupService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/UserGroupService.java @@ -7,7 +7,7 @@ package org.gridsuite.useradmin.server.service; import jakarta.annotation.Nullable; -import org.gridsuite.useradmin.server.UserAdminException; +import org.gridsuite.useradmin.server.error.UserAdminException; import org.gridsuite.useradmin.server.dto.UserGroup; import org.gridsuite.useradmin.server.entity.GroupInfosEntity; import org.gridsuite.useradmin.server.entity.UserInfosEntity; @@ -21,9 +21,6 @@ import java.util.*; import java.util.stream.Collectors; -import static org.gridsuite.useradmin.server.UserAdminException.Type.GROUP_ALREADY_EXISTS; -import static org.gridsuite.useradmin.server.UserAdminException.Type.NOT_FOUND; - /** * @author Franck Lecuyer */ @@ -65,7 +62,8 @@ public Optional getGroupIfAdmin(String group) { @Transactional() public void updateGroup(UUID groupUuid, UserGroup userGroup) { adminRightService.assertIsAdmin(); - GroupInfosEntity group = userGroupRepository.findById(groupUuid).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + GroupInfosEntity group = userGroupRepository.findById(groupUuid) + .orElseThrow(() -> UserAdminException.groupNotFound(groupUuid)); group.setName(userGroup.name()); // remove group from all of his existing users @@ -97,7 +95,7 @@ public void createGroup(String group) { adminRightService.assertIsAdmin(); Optional groupInfosEntity = userGroupRepository.findByName(group); if (groupInfosEntity.isPresent()) { - throw new UserAdminException(GROUP_ALREADY_EXISTS); + throw UserAdminException.groupAlreadyExists(group); } userGroupRepository.save(new GroupInfosEntity(group)); } @@ -108,7 +106,8 @@ public long deleteGroups(List names) { // check if group contains users names.forEach(name -> { - GroupInfosEntity group = userGroupRepository.findByName(name).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + GroupInfosEntity group = userGroupRepository.findByName(name) + .orElseThrow(() -> UserAdminException.groupNotFound(name)); if (!CollectionUtils.isEmpty(group.getUsers())) { throw new DataIntegrityViolationException("Group " + name + " contains users !"); } diff --git a/src/main/java/org/gridsuite/useradmin/server/service/UserProfileService.java b/src/main/java/org/gridsuite/useradmin/server/service/UserProfileService.java index 0c4f442..23dbabb 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/UserProfileService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/UserProfileService.java @@ -9,7 +9,7 @@ import com.google.common.collect.Sets; import org.apache.commons.lang3.BooleanUtils; import org.gridsuite.useradmin.server.UserAdminApplicationProps; -import org.gridsuite.useradmin.server.UserAdminException; +import org.gridsuite.useradmin.server.error.UserAdminException; import org.gridsuite.useradmin.server.dto.UserProfile; import org.gridsuite.useradmin.server.entity.UserProfileEntity; import org.gridsuite.useradmin.server.repository.UserProfileRepository; @@ -20,9 +20,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.gridsuite.useradmin.server.UserAdminException.Type.NOT_FOUND; -import static org.gridsuite.useradmin.server.UserAdminException.Type.PROFILE_ALREADY_EXISTS; - /** * @author David Braquart */ @@ -117,7 +114,8 @@ public Optional getProfileIfAdmin(UUID profileUuid) { @Transactional() public void updateProfile(UUID profileUuid, UserProfile userProfile) { adminRightService.assertIsAdmin(); - UserProfileEntity profile = userProfileRepository.findById(profileUuid).orElseThrow(() -> new UserAdminException(NOT_FOUND)); + UserProfileEntity profile = userProfileRepository.findById(profileUuid) + .orElseThrow(() -> UserAdminException.profileNotFound(profileUuid)); profile.setName(userProfile.name()); profile.setLoadFlowParameterId(userProfile.loadFlowParameterId()); profile.setSecurityAnalysisParameterId(userProfile.securityAnalysisParameterId()); @@ -135,7 +133,7 @@ public void updateProfile(UUID profileUuid, UserProfile userProfile) { public void createProfile(UserProfile userProfile) { adminRightService.assertIsAdmin(); if (userProfileRepository.findByName(userProfile.name()).isPresent()) { - throw new UserAdminException(PROFILE_ALREADY_EXISTS); + throw UserAdminException.profileAlreadyExists(userProfile.name()); } UserProfileEntity userProfileEntity = toEntity(userProfile); userProfileRepository.save(userProfileEntity); diff --git a/src/test/java/org/gridsuite/useradmin/server/AnnouncementTest.java b/src/test/java/org/gridsuite/useradmin/server/AnnouncementTest.java index 4c31c8b..f53b425 100644 --- a/src/test/java/org/gridsuite/useradmin/server/AnnouncementTest.java +++ b/src/test/java/org/gridsuite/useradmin/server/AnnouncementTest.java @@ -6,11 +6,13 @@ */ package org.gridsuite.useradmin.server; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.assertj.core.api.WithAssertions; import org.gridsuite.useradmin.server.dto.Announcement; import org.gridsuite.useradmin.server.entity.AnnouncementEntity; import org.gridsuite.useradmin.server.entity.AnnouncementSeverity; +import org.gridsuite.useradmin.server.error.UserAdminBusinessErrorCode; import org.gridsuite.useradmin.server.repository.AnnouncementRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -31,7 +33,6 @@ import java.util.List; import java.util.UUID; -import static org.gridsuite.useradmin.server.UserAdminException.Type.*; import static org.gridsuite.useradmin.server.Utils.ROLES_HEADER; import static org.gridsuite.useradmin.server.service.NotificationService.HEADER_MESSAGE_TYPE; import static org.gridsuite.useradmin.server.service.NotificationService.MESSAGE_TYPE_CANCEL_ANNOUNCEMENT; @@ -81,7 +82,7 @@ private void assertQueuesEmptyThenClear(List destinations, OutputDestina @Test void testCreateAnnouncement() throws Exception { - Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant now = Instant.parse("2025-10-13T11:05:32Z"); Announcement announcementToBeCreated = new Announcement(UUID.randomUUID(), now, now.plus(2, ChronoUnit.DAYS), "Test message", AnnouncementSeverity.INFO); assertEquals(0, announcementRepository.findAll().size()); @@ -108,36 +109,42 @@ void testCreateAnnouncement() throws Exception { ) .andExpect(status().isBadRequest()) .andReturn(); - assertTrue(result.getResponse().getContentAsString().contains("endDate\":\"must be a future date\"")); + + JsonNode problem1 = objectMapper.readTree(result.getResponse().getContentAsString()); assertEquals(0, announcementRepository.findAll().size()); + assertEquals(UserAdminBusinessErrorCode.USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD.value(), problem1.get("businessErrorCode").asText()); + assertEquals("Announcement end date '2025-10-13T11:05:32Z' must be after start date '2025-10-15T11:05:32Z'", problem1.get("detail").asText()); - // Should NOT be ok because severity doesn't exist + // Should NOT be ok because startDate = endDate result = mockMvc.perform(put("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", ADMIN_USER) .header(ROLES_HEADER, USER_ADMIN_ROLE) - .queryParam("severity", "NOT A SEVERITY") + .queryParam("severity", announcementToBeCreated.severity().name()) .queryParam("startDate", announcementToBeCreated.startDate().toString()) - .queryParam("endDate", announcementToBeCreated.endDate().toString()) + .queryParam("endDate", announcementToBeCreated.startDate().toString()) .content(announcementToBeCreated.message()) ) .andExpect(status().isBadRequest()) .andReturn(); - assertTrue(result.getResponse().getContentAsString().contains("\"Failed to convert 'severity' with value: 'NOT A SEVERITY'\"")); + JsonNode problem2 = objectMapper.readTree(result.getResponse().getContentAsString()); + assertEquals(0, announcementRepository.findAll().size()); + assertEquals(UserAdminBusinessErrorCode.USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD.value(), problem2.get("businessErrorCode").asText()); + assertEquals("Announcement end date '2025-10-13T11:05:32Z' must be after start date '2025-10-13T11:05:32Z'", problem2.get("detail").asText()); - // Should NOT be ok because startDate = endDate + // Should NOT be ok because severity doesn't exist result = mockMvc.perform(put("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", ADMIN_USER) .header(ROLES_HEADER, USER_ADMIN_ROLE) - .queryParam("severity", announcementToBeCreated.severity().name()) + .queryParam("severity", "NOT A SEVERITY") .queryParam("startDate", announcementToBeCreated.startDate().toString()) - .queryParam("endDate", announcementToBeCreated.startDate().toString()) + .queryParam("endDate", announcementToBeCreated.endDate().toString()) .content(announcementToBeCreated.message()) ) - .andExpect(status().isBadRequest()) + .andExpect(status().isInternalServerError()) .andReturn(); - assertTrue(result.getResponse().getContentAsString().contains("endDate\":\"must be a future date\"")); - assertEquals(0, announcementRepository.findAll().size()); + + assertTrue(result.getResponse().getContentAsString().contains("\"Failed to convert value of type 'java.lang.String' to required type 'org.gridsuite.useradmin.server.entity.AnnouncementSeverity'")); // Should be ok because user is admin result = mockMvc.perform(put("/" + UserAdminApi.API_VERSION + "/announcements") @@ -179,7 +186,9 @@ void testCreateAnnouncement() throws Exception { .usingRecursiveComparison() .ignoringFields("id", "remainingDuration") .isEqualTo(announcementToBeCreated); - assertTrue(result.getResponse().getContentAsString().contains(OVERLAPPING_ANNOUNCEMENTS.name())); + JsonNode overlapProblem = objectMapper.readTree(result.getResponse().getContentAsString()); + assertEquals(UserAdminBusinessErrorCode.USER_ADMIN_ANNOUNCEMENT_OVERLAP.value(), + overlapProblem.get("businessErrorCode").asText()); } @Test diff --git a/src/test/java/org/gridsuite/useradmin/server/PropertyServerNameProviderTest.java b/src/test/java/org/gridsuite/useradmin/server/PropertyServerNameProviderTest.java new file mode 100644 index 0000000..c3d6ac0 --- /dev/null +++ b/src/test/java/org/gridsuite/useradmin/server/PropertyServerNameProviderTest.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class PropertyServerNameProviderTest { + + @Test + void returnsConfiguredName() { + PropertyServerNameProvider provider = new PropertyServerNameProvider("user-admin-custom"); + assertThat(provider.serverName()).isEqualTo("user-admin-custom"); + } +} diff --git a/src/test/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandlerTest.java b/src/test/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandlerTest.java new file mode 100644 index 0000000..fac3ed2 --- /dev/null +++ b/src/test/java/org/gridsuite/useradmin/server/RestResponseEntityExceptionHandlerTest.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; +import org.gridsuite.useradmin.server.error.RestResponseEntityExceptionHandler; +import org.gridsuite.useradmin.server.error.UserAdminException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.client.HttpClientErrorException; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class RestResponseEntityExceptionHandlerTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); + + private TestRestResponseEntityExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new TestRestResponseEntityExceptionHandler(); + } + + @Test + void mapsBusinessCodeToStatus() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/user-admin"); + UserAdminException exception = UserAdminException.forbidden(); + + ResponseEntity response = handler.invokeHandleDomainException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertEquals("useradmin.permissionDenied", response.getBody().getBusinessErrorCode()); + } + + @Test + void propagatesRemoteDetails() throws JsonProcessingException { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/user-admin/remote"); + PowsyblWsProblemDetail remote = PowsyblWsProblemDetail.builder(HttpStatus.BAD_GATEWAY) + .server("directory") + .detail("failure") + .path("/directory") + .build(); + + HttpClientErrorException exception = HttpClientErrorException.create( + HttpStatus.BAD_GATEWAY, + "coucou", + HttpHeaders.EMPTY, + OBJECT_MAPPER.writeValueAsBytes(remote), + null + ); + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getChain()).hasSize(1); + } + + @Test + void wrapsInvalidRemotePayload() { + MockHttpServletRequest request = new MockHttpServletRequest("DELETE", "/user-admin/remote"); + HttpClientErrorException exception = HttpClientErrorException.create( + HttpStatus.BAD_GATEWAY, + "bad gateway", + null, + "oops".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + assertThat(response.getBody()).isNotNull(); + } + + @Test + void keepsRemoteStatusFromPayload() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/user-admin/remote"); + PowsyblWsProblemDetail remote = PowsyblWsProblemDetail.builder(HttpStatus.BAD_REQUEST) + .server("directory") + .businessErrorCode("directory.remoteError") + .detail("invalid") + .path("/directory") + .build(); + + byte[] payload = OBJECT_MAPPER.writeValueAsBytes(remote); + HttpClientErrorException exception = HttpClientErrorException.create(HttpStatus.BAD_REQUEST, "bad request", + null, payload, StandardCharsets.UTF_8); + + ResponseEntity response = handler.invokeHandleRemoteException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertEquals("directory.remoteError", response.getBody().getBusinessErrorCode()); + assertThat(response.getBody().getChain()).hasSize(1); + } + + private static final class TestRestResponseEntityExceptionHandler extends RestResponseEntityExceptionHandler { + + private TestRestResponseEntityExceptionHandler() { + super(() -> "user-admin-server"); + } + + ResponseEntity invokeHandleDomainException(UserAdminException exception, MockHttpServletRequest request) { + return super.handleDomainException(exception, request); + } + + ResponseEntity invokeHandleRemoteException(HttpClientErrorException exception, MockHttpServletRequest request) { + return super.handleRemoteException(exception, request); + } + } +} diff --git a/src/test/java/org/gridsuite/useradmin/server/UserAdminBusinessErrorCodeTest.java b/src/test/java/org/gridsuite/useradmin/server/UserAdminBusinessErrorCodeTest.java new file mode 100644 index 0000000..67d2ab6 --- /dev/null +++ b/src/test/java/org/gridsuite/useradmin/server/UserAdminBusinessErrorCodeTest.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server; + +import org.gridsuite.useradmin.server.error.UserAdminBusinessErrorCode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class UserAdminBusinessErrorCodeTest { + + @ParameterizedTest + @EnumSource(UserAdminBusinessErrorCode.class) + void valueStartsWithPrefix(UserAdminBusinessErrorCode code) { + assertThat(code.value()).startsWith("useradmin."); + } +} diff --git a/src/test/java/org/gridsuite/useradmin/server/UserAdminExceptionTest.java b/src/test/java/org/gridsuite/useradmin/server/UserAdminExceptionTest.java new file mode 100644 index 0000000..871d108 --- /dev/null +++ b/src/test/java/org/gridsuite/useradmin/server/UserAdminExceptionTest.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.gridsuite.useradmin.server; + +import org.gridsuite.useradmin.server.error.UserAdminBusinessErrorCode; +import org.gridsuite.useradmin.server.error.UserAdminException; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Mohamed Ben-rejeb {@literal } + */ +class UserAdminExceptionTest { + + @Test + void staticFactoriesCoverAllPaths() { + assertThat(UserAdminException.forbidden().getBusinessErrorCode()) + .isEqualTo(UserAdminBusinessErrorCode.USER_ADMIN_PERMISSION_DENIED); + + assertThat(UserAdminException.userAlreadyExists("subj").getMessage()).contains("subj"); + assertThat(UserAdminException.userNotFound("subj").getMessage()).contains("subj"); + + assertThat(UserAdminException.profileAlreadyExists("profile").getBusinessErrorCode()) + .isEqualTo(UserAdminBusinessErrorCode.USER_ADMIN_PROFILE_ALREADY_EXISTS); + + UUID profileId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + assertThat(UserAdminException.profileNotFound(profileId).getMessage()).contains(profileId.toString()); + + assertThat(UserAdminException.groupAlreadyExists("group").getBusinessErrorCode()) + .isEqualTo(UserAdminBusinessErrorCode.USER_ADMIN_GROUP_ALREADY_EXISTS); + + UUID groupId = UUID.fromString("00000000-0000-0000-0000-000000000002"); + assertThat(UserAdminException.groupNotFound(groupId).getMessage()).contains(groupId.toString()); + assertThat(UserAdminException.groupNotFound("group").getMessage()).contains("group"); + + Instant start = Instant.parse("2025-12-01T00:00:00Z"); + Instant end = Instant.parse("2025-12-02T00:00:00Z"); + assertThat(UserAdminException.announcementInvalidPeriod(start, end).getBusinessErrorCode()) + .isEqualTo(UserAdminBusinessErrorCode.USER_ADMIN_ANNOUNCEMENT_INVALID_PERIOD); + assertThat(UserAdminException.announcementOverlap(start, end).getBusinessErrorCode()) + .isEqualTo(UserAdminBusinessErrorCode.USER_ADMIN_ANNOUNCEMENT_OVERLAP); + + UserAdminException formatted = UserAdminException.of(UserAdminBusinessErrorCode.USER_ADMIN_USER_NOT_FOUND, + "User %s missing", "x"); + assertThat(formatted.getMessage()).isEqualTo("User x missing"); + } +} diff --git a/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java b/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java index f6b8359..00b165b 100644 --- a/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java +++ b/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java @@ -193,7 +193,7 @@ void testUserAdmin() throws Exception { .header(ROLES_HEADER, USER_ADMIN_ROLE) .contentType(APPLICATION_JSON) .content("[]")) - .andExpect(status().isBadRequest()) + .andExpect(status().isInternalServerError()) .andReturn(); mockMvc.perform(delete("/" + UserAdminApi.API_VERSION + "/users")