From 81b8b710feee2fbe4cfe221e53ecaf09585b413d Mon Sep 17 00:00:00 2001 From: Abdelsalem <46495975+AbdelHedhili@users.noreply.github.com> Date: Fri, 21 Jun 2024 10:39:23 +0200 Subject: [PATCH] check user case creation limit (#91) Signed-off-by: Abdelsalem --- .../explore/server/ExploreController.java | 4 + .../explore/server/ExploreException.java | 3 +- .../RestResponseEntityExceptionHandler.java | 2 + .../explore/server/services/CaseService.java | 9 +- .../server/services/DirectoryService.java | 13 ++ .../server/services/ExploreService.java | 29 ++- .../server/services/UserAdminService.java | 52 +++++ .../explore/server/utils/ExploreUtils.java | 29 +++ src/main/resources/application-local.yml | 3 + .../gridsuite/explore/server/ExploreTest.java | 186 +++++++++++++++++- 10 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/main/java/org/gridsuite/explore/server/services/UserAdminService.java create mode 100644 src/main/java/org/gridsuite/explore/server/utils/ExploreUtils.java diff --git a/src/main/java/org/gridsuite/explore/server/ExploreController.java b/src/main/java/org/gridsuite/explore/server/ExploreController.java index de057351..3e34701b 100644 --- a/src/main/java/org/gridsuite/explore/server/ExploreController.java +++ b/src/main/java/org/gridsuite/explore/server/ExploreController.java @@ -56,6 +56,7 @@ public ResponseEntity createStudy(@PathVariable("studyName") String studyN @RequestParam(QUERY_PARAM_PARENT_DIRECTORY_ID) UUID parentDirectoryUuid, @RequestHeader("userId") String userId, @RequestBody(required = false) Map importParams) { + exploreService.assertCanCreateCase(userId); CaseInfo caseInfo = new CaseInfo(caseUuid, caseFormat); exploreService.createStudy(studyName, caseInfo, description, userId, parentDirectoryUuid, importParams, duplicateCase); return ResponseEntity.ok().build(); @@ -67,6 +68,7 @@ public ResponseEntity createStudy(@PathVariable("studyName") String studyN public ResponseEntity duplicateStudy(@RequestParam("duplicateFrom") UUID studyId, @RequestParam(name = QUERY_PARAM_PARENT_DIRECTORY_ID, required = false) UUID targetDirectoryId, @RequestHeader("userId") String userId) { + exploreService.assertCanCreateCase(userId); exploreService.duplicateStudy(studyId, targetDirectoryId, userId); return ResponseEntity.ok().build(); } @@ -79,6 +81,7 @@ public ResponseEntity createCase(@PathVariable("caseName") String caseName @RequestParam("description") String description, @RequestParam(QUERY_PARAM_PARENT_DIRECTORY_ID) UUID parentDirectoryUuid, @RequestHeader("userId") String userId) { + exploreService.assertCanCreateCase(userId); exploreService.createCase(caseName, caseFile, description, userId, parentDirectoryUuid); return ResponseEntity.ok().build(); } @@ -90,6 +93,7 @@ public ResponseEntity duplicateCase( @RequestParam("duplicateFrom") UUID caseId, @RequestParam(name = QUERY_PARAM_PARENT_DIRECTORY_ID, required = false) UUID targetDirectoryId, @RequestHeader("userId") String userId) { + exploreService.assertCanCreateCase(userId); exploreService.duplicateCase(caseId, targetDirectoryId, userId); return ResponseEntity.ok().build(); } diff --git a/src/main/java/org/gridsuite/explore/server/ExploreException.java b/src/main/java/org/gridsuite/explore/server/ExploreException.java index d041d3da..7fed47fe 100644 --- a/src/main/java/org/gridsuite/explore/server/ExploreException.java +++ b/src/main/java/org/gridsuite/explore/server/ExploreException.java @@ -19,7 +19,8 @@ public enum Type { UNKNOWN_ELEMENT_TYPE, REMOTE_ERROR, IMPORT_CASE_FAILED, - INCORRECT_CASE_FILE + INCORRECT_CASE_FILE, + MAX_ELEMENTS_EXCEEDED, } private final Type type; diff --git a/src/main/java/org/gridsuite/explore/server/RestResponseEntityExceptionHandler.java b/src/main/java/org/gridsuite/explore/server/RestResponseEntityExceptionHandler.java index 25a9983b..8a0a8226 100644 --- a/src/main/java/org/gridsuite/explore/server/RestResponseEntityExceptionHandler.java +++ b/src/main/java/org/gridsuite/explore/server/RestResponseEntityExceptionHandler.java @@ -42,6 +42,8 @@ protected ResponseEntity handleExploreException(ExploreException excepti return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(exception.getMessage()); case UNKNOWN_ELEMENT_TYPE: return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(UNKNOWN_ELEMENT_TYPE); + case MAX_ELEMENTS_EXCEEDED: + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(MAX_ELEMENTS_EXCEEDED + " " + exception.getMessage()); default: return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } diff --git a/src/main/java/org/gridsuite/explore/server/services/CaseService.java b/src/main/java/org/gridsuite/explore/server/services/CaseService.java index 4568ee82..11a7515b 100644 --- a/src/main/java/org/gridsuite/explore/server/services/CaseService.java +++ b/src/main/java/org/gridsuite/explore/server/services/CaseService.java @@ -27,6 +27,7 @@ import java.util.stream.Collectors; import static org.gridsuite.explore.server.ExploreException.Type.*; +import static org.gridsuite.explore.server.utils.ExploreUtils.wrapRemoteError; @Service public class CaseService implements IDirectoryElementsService { @@ -43,14 +44,6 @@ public CaseService(@Value("${powsybl.services.case-server.base-uri:http://case-s this.restTemplate = restTemplate; } - private static ExploreException wrapRemoteError(String response, HttpStatusCode statusCode) { - if (!"".equals(response)) { - throw new ExploreException(ExploreException.Type.REMOTE_ERROR, response); - } else { - throw new ExploreException(ExploreException.Type.REMOTE_ERROR, "{\"message\": " + statusCode + "\"}"); - } - } - public void setBaseUri(String actionsServerBaseUri) { this.caseServerBaseUri = actionsServerBaseUri; } diff --git a/src/main/java/org/gridsuite/explore/server/services/DirectoryService.java b/src/main/java/org/gridsuite/explore/server/services/DirectoryService.java index 498284bb..8a20f0d9 100644 --- a/src/main/java/org/gridsuite/explore/server/services/DirectoryService.java +++ b/src/main/java/org/gridsuite/explore/server/services/DirectoryService.java @@ -188,6 +188,19 @@ private List getElementsInfos(List elementsUuids, List< } } + public int getUserCasesCount(String userId) { + String path = UriComponentsBuilder + .fromPath(DELIMITER + DIRECTORY_SERVER_API_VERSION + DELIMITER + "users/{userId}/cases/count") + .buildAndExpand(userId) + .toUriString(); + + Integer casesCount = restTemplate.exchange(directoryServerBaseUri + path, HttpMethod.GET, null, Integer.class).getBody(); + if (casesCount == null) { + throw new ExploreException(REMOTE_ERROR, "Could not get cases count"); + } + return casesCount; + } + public void notifyDirectoryChanged(UUID elementUuid, String userId) { String path = UriComponentsBuilder .fromPath(ELEMENTS_SERVER_ROOT_PATH + "/{elementUuid}/notification?type={update_directory}") diff --git a/src/main/java/org/gridsuite/explore/server/services/ExploreService.java b/src/main/java/org/gridsuite/explore/server/services/ExploreService.java index 60f44df3..c4d8ad39 100644 --- a/src/main/java/org/gridsuite/explore/server/services/ExploreService.java +++ b/src/main/java/org/gridsuite/explore/server/services/ExploreService.java @@ -20,8 +20,7 @@ import java.util.Map; import java.util.UUID; -import static org.gridsuite.explore.server.ExploreException.Type.NOT_ALLOWED; -import static org.gridsuite.explore.server.ExploreException.Type.UNKNOWN_ELEMENT_TYPE; +import static org.gridsuite.explore.server.ExploreException.Type.*; /** @@ -47,15 +46,16 @@ public class ExploreService { private final ParametersService parametersService; private static final Logger LOGGER = LoggerFactory.getLogger(ExploreService.class); + private final UserAdminService userAdminService; public ExploreService( - DirectoryService directoryService, - StudyService studyService, - ContingencyListService contingencyListService, - FilterService filterService, - NetworkModificationService networkModificationService, - CaseService caseService, - ParametersService parametersService) { + DirectoryService directoryService, + StudyService studyService, + ContingencyListService contingencyListService, + FilterService filterService, + NetworkModificationService networkModificationService, + CaseService caseService, + ParametersService parametersService, UserAdminService userAdminService) { this.directoryService = directoryService; this.studyService = studyService; @@ -64,6 +64,7 @@ public ExploreService( this.networkModificationService = networkModificationService; this.caseService = caseService; this.parametersService = parametersService; + this.userAdminService = userAdminService; } public void createStudy(String studyName, CaseInfo caseInfo, String description, String userId, UUID parentDirectoryUuid, Map importParams, Boolean duplicateCase) { @@ -275,4 +276,14 @@ public void duplicateNetworkModifications(UUID sourceId, UUID parentDirectoryUui // create corresponding directory element directoryService.duplicateElement(sourceId, newNetworkModification, parentDirectoryUuid, userId); } + + public void assertCanCreateCase(String userId) { + Integer userMaxAllowedStudiesAndCases = userAdminService.getUserMaxAllowedCases(userId); + if (userMaxAllowedStudiesAndCases != null) { + int userCasesCount = directoryService.getUserCasesCount(userId); + if (userCasesCount >= userMaxAllowedStudiesAndCases) { + throw new ExploreException(MAX_ELEMENTS_EXCEEDED, "max allowed cases : " + userMaxAllowedStudiesAndCases); + } + } + } } diff --git a/src/main/java/org/gridsuite/explore/server/services/UserAdminService.java b/src/main/java/org/gridsuite/explore/server/services/UserAdminService.java new file mode 100644 index 00000000..9274691c --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/services/UserAdminService.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2024, 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.explore.server.services; + +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.gridsuite.explore.server.utils.ExploreUtils.wrapRemoteError; + +/** + * @author Abdelsalem Hedhili + */ +@Service +public class UserAdminService { + + private static final String USER_ADMIN_API_VERSION = "v1"; + private static final String USERS_MAX_ALLOWED_CASES_URI = "/users/{sub}/profile/max-cases"; + + private static final String DELIMITER = "/"; + private final RestTemplate restTemplate; + @Setter + private String userAdminServerBaseUri; + + @Autowired + public UserAdminService(RestTemplate restTemplate, RemoteServicesProperties remoteServicesProperties) { + this.userAdminServerBaseUri = remoteServicesProperties.getServiceUri("user-admin-server"); + this.restTemplate = restTemplate; + } + + public Integer getUserMaxAllowedCases(String sub) { + String path = UriComponentsBuilder.fromPath(DELIMITER + USER_ADMIN_API_VERSION + USERS_MAX_ALLOWED_CASES_URI) + .buildAndExpand(sub).toUriString(); + try { + return restTemplate.getForObject(userAdminServerBaseUri + path, Integer.class); + } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 404) { + return null; // no profile == unlimited import + } + throw wrapRemoteError(e.getMessage(), e.getStatusCode()); + + } + } + +} diff --git a/src/main/java/org/gridsuite/explore/server/utils/ExploreUtils.java b/src/main/java/org/gridsuite/explore/server/utils/ExploreUtils.java new file mode 100644 index 00000000..f1d1f638 --- /dev/null +++ b/src/main/java/org/gridsuite/explore/server/utils/ExploreUtils.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024, 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.explore.server.utils; + +import org.gridsuite.explore.server.ExploreException; +import org.springframework.http.HttpStatusCode; + +/** + * @author Abdelsalem Hedhili s.setBaseUri(baseUrl)); String privateStudyAttributesAsString = mapper.writeValueAsString(new ElementAttributes(PRIVATE_STUDY_UUID, STUDY1, "STUDY", USER1, 0, null)); @@ -299,6 +306,30 @@ public MockResponse dispatch(RecordedRequest request) { } else if (path.matches("/v1/studies/metadata[?]ids=" + PRIVATE_STUDY_UUID)) { return new MockResponse().setBody(listOfPrivateStudyAttributesAsString.replace("elementUuid", "id")).setResponseCode(200) .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_WITH_CASE_LIMIT_EXCEEDED + "/profile/max-cases")) { + return new MockResponse().setBody("3").setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_WITH_CASE_LIMIT_NOT_EXCEEDED + "/profile/max-cases")) { + return new MockResponse().setBody("5").setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_NOT_FOUND + "/profile/max-cases")) { + return new MockResponse().setResponseCode(404) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_UNEXPECTED_ERROR + "/profile/max-cases")) { + return new MockResponse().setResponseCode(500) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/.*/profile/max-cases")) { + return new MockResponse().setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_WITH_CASE_LIMIT_EXCEEDED + "/cases/count")) { + return new MockResponse().setBody("4").setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/" + USER_WITH_CASE_LIMIT_NOT_EXCEEDED + "/cases/count")) { + return new MockResponse().setBody("2").setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); + } else if (path.matches("/v1/users/.*/cases/count")) { + return new MockResponse().setBody("0").setResponseCode(200) + .addHeader("Content-Type", "application/json; charset=utf-8"); } } else if ("DELETE".equals(request.getMethod())) { if (path.matches("/v1/filters/" + FILTER_UUID)) { @@ -752,4 +783,155 @@ public void testGetModificationMetadata() { assertEquals(1, elementsMetadata.size()); assertEquals(mapper.writeValueAsString(elementsMetadata.get(0)), expectedResult); } + + @Test + public void testMaxCaseCreationExceeded() throws Exception { + + //test create a study with a user that already exceeded his cases limit + MvcResult result = mockMvc.perform(post("/v1/explore/studies/" + STUDY1 + "/cases/" + CASE_UUID + "?description=desc&parentDirectoryUuid=" + PARENT_DIRECTORY_UUID) + .param("duplicateCase", "false") + .header("userId", USER_WITH_CASE_LIMIT_EXCEEDED) + .param("caseFormat", "XIIDM") + .contentType(APPLICATION_JSON) + ).andExpect(status().isForbidden()) + .andReturn(); + assertTrue(result.getResponse().getContentAsString().contains(MAX_ELEMENTS_EXCEEDED.name())); + + //test duplicate a study with a user that already exceeded his cases limit + result = mockMvc.perform(post("/v1/explore/studies?duplicateFrom={studyUuid}&parentDirectoryUuid={parentDirectoryUuid}", + PUBLIC_STUDY_UUID, PARENT_DIRECTORY_UUID) + .header("userId", USER_WITH_CASE_LIMIT_EXCEEDED) + ).andExpect(status().isForbidden()) + .andReturn(); + assertTrue(result.getResponse().getContentAsString().contains(MAX_ELEMENTS_EXCEEDED.name())); + + //test duplicate a case with a user that already exceeded his cases limit + result = mockMvc.perform(post("/v1/explore/cases?duplicateFrom={caseUuid}&parentDirectoryUuid={parentDirectoryUuid}", + CASE_UUID, PARENT_DIRECTORY_UUID).header("userId", USER_WITH_CASE_LIMIT_EXCEEDED)) + .andExpect(status().isForbidden()) + .andReturn(); + assertTrue(result.getResponse().getContentAsString().contains(MAX_ELEMENTS_EXCEEDED.name())); + + //test create a case with a user that already exceeded his cases limit + try (InputStream is = new FileInputStream(ResourceUtils.getFile("classpath:" + TEST_FILE))) { + MockMultipartFile mockFile = new MockMultipartFile("caseFile", TEST_FILE, "text/xml", is); + + result = mockMvc.perform(multipart("/v1/explore/cases/{caseName}?description={description}&parentDirectoryUuid={parentDirectoryUuid}", + STUDY1, "description", PARENT_DIRECTORY_UUID).file(mockFile) + .header("userId", USER_WITH_CASE_LIMIT_EXCEEDED) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isForbidden()) + .andReturn(); + assertTrue(result.getResponse().getContentAsString().contains(MAX_ELEMENTS_EXCEEDED.name())); + assertTrue(result.getResponse().getContentAsString().contains("max allowed cases : 3")); + } + } + + @Test + public void testMaxCaseCreationNotExceeded() throws Exception { + + //test create a study with a user that hasn't already exceeded his cases limit + mockMvc.perform(post("/v1/explore/studies/" + STUDY1 + "/cases/" + CASE_UUID + "?description=desc&parentDirectoryUuid=" + PARENT_DIRECTORY_UUID) + .param("duplicateCase", "false") + .header("userId", USER_WITH_CASE_LIMIT_NOT_EXCEEDED) + .param("caseFormat", "XIIDM") + .contentType(APPLICATION_JSON) + ).andExpect(status().isOk()); + + //test duplicate a study with a user that hasn't already exceeded his cases limit + mockMvc.perform(post("/v1/explore/studies?duplicateFrom={studyUuid}&parentDirectoryUuid={parentDirectoryUuid}", + PUBLIC_STUDY_UUID, PARENT_DIRECTORY_UUID) + .header("userId", USER_WITH_CASE_LIMIT_NOT_EXCEEDED) + ).andExpect(status().isOk()); + + //test duplicate a case with a user that hasn't already exceeded his cases limit + mockMvc.perform(post("/v1/explore/cases?duplicateFrom={caseUuid}&parentDirectoryUuid={parentDirectoryUuid}", + CASE_UUID, PARENT_DIRECTORY_UUID).header("userId", USER_WITH_CASE_LIMIT_NOT_EXCEEDED)) + .andExpect(status().isOk()); + + //test create a case with a user that hasn't already exceeded his cases limit + try (InputStream is = new FileInputStream(ResourceUtils.getFile("classpath:" + TEST_FILE))) { + MockMultipartFile mockFile = new MockMultipartFile("caseFile", TEST_FILE, "text/xml", is); + + mockMvc.perform(multipart("/v1/explore/cases/{caseName}?description={description}&parentDirectoryUuid={parentDirectoryUuid}", + STUDY1, "description", PARENT_DIRECTORY_UUID).file(mockFile) + .header("userId", USER_WITH_CASE_LIMIT_NOT_EXCEEDED) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isOk()) + .andReturn(); + } + } + + @Test + public void testMaxCaseCreationProfileNotSet() throws Exception { + + //test create a study with a user that has no profile to limit his case creation + mockMvc.perform(post("/v1/explore/studies/" + STUDY1 + "/cases/" + CASE_UUID + "?description=desc&parentDirectoryUuid=" + PARENT_DIRECTORY_UUID) + .param("duplicateCase", "false") + .header("userId", USER_NOT_FOUND) + .param("caseFormat", "XIIDM") + .contentType(APPLICATION_JSON) + ).andExpect(status().isOk()); + + //test duplicate a study with a user that has no profile to limit his case creation + mockMvc.perform(post("/v1/explore/studies?duplicateFrom={studyUuid}&parentDirectoryUuid={parentDirectoryUuid}", + PUBLIC_STUDY_UUID, PARENT_DIRECTORY_UUID) + .header("userId", USER_NOT_FOUND) + ).andExpect(status().isOk()); + + //test duplicate a case with a user that has no profile to limit his case creation + mockMvc.perform(post("/v1/explore/cases?duplicateFrom={caseUuid}&parentDirectoryUuid={parentDirectoryUuid}", + CASE_UUID, PARENT_DIRECTORY_UUID).header("userId", USER_NOT_FOUND)) + .andExpect(status().isOk()); + + //test create a case with a user that has no profile to limit his case creation + try (InputStream is = new FileInputStream(ResourceUtils.getFile("classpath:" + TEST_FILE))) { + MockMultipartFile mockFile = new MockMultipartFile("caseFile", TEST_FILE, "text/xml", is); + + mockMvc.perform(multipart("/v1/explore/cases/{caseName}?description={description}&parentDirectoryUuid={parentDirectoryUuid}", + STUDY1, "description", PARENT_DIRECTORY_UUID).file(mockFile) + .header("userId", USER_NOT_FOUND) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isOk()) + .andReturn(); + } + } + + @Test + public void testMaxCaseCreationWithRemoteException() throws Exception { + + //test create a study with a remote unexpected exception + mockMvc.perform(post("/v1/explore/studies/" + STUDY1 + "/cases/" + CASE_UUID + "?description=desc&parentDirectoryUuid=" + PARENT_DIRECTORY_UUID) + .param("duplicateCase", "false") + .header("userId", USER_UNEXPECTED_ERROR) + .param("caseFormat", "XIIDM") + .contentType(APPLICATION_JSON) + ).andExpect(status().isBadRequest()); + + //test duplicate a study with a remote unexpected exception + mockMvc.perform(post("/v1/explore/studies?duplicateFrom={studyUuid}&parentDirectoryUuid={parentDirectoryUuid}", + PUBLIC_STUDY_UUID, PARENT_DIRECTORY_UUID) + .header("userId", USER_UNEXPECTED_ERROR) + ).andExpect(status().isBadRequest()); + + //test duplicate a case with a remote unexpected exception + mockMvc.perform(post("/v1/explore/cases?duplicateFrom={caseUuid}&parentDirectoryUuid={parentDirectoryUuid}", + CASE_UUID, PARENT_DIRECTORY_UUID).header("userId", USER_UNEXPECTED_ERROR)) + .andExpect(status().isBadRequest()); + + //test create a case with a remote unexpected exception + try (InputStream is = new FileInputStream(ResourceUtils.getFile("classpath:" + TEST_FILE))) { + MockMultipartFile mockFile = new MockMultipartFile("caseFile", TEST_FILE, "text/xml", is); + + mockMvc.perform(multipart("/v1/explore/cases/{caseName}?description={description}&parentDirectoryUuid={parentDirectoryUuid}", + STUDY1, "description", PARENT_DIRECTORY_UUID).file(mockFile) + .header("userId", USER_UNEXPECTED_ERROR) + .contentType(MediaType.MULTIPART_FORM_DATA) + ) + .andExpect(status().isBadRequest()); + } + } }