From 6383c6110a8bdd3c1e3bb28c3220bdfc6d660645 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 08:53:14 -0800 Subject: [PATCH 01/36] Update versions for hotfix --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b9e9ec79..5db2d732 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ ubc.pavlab rdp - 1.4.7 + 1.4.8 From 1e6dc02027657a7b48690b3294cef290e5525933 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 08:54:22 -0800 Subject: [PATCH 02/36] Fix taxon ordering when the field is null --- src/main/java/ubc/pavlab/rdp/model/User.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/model/User.java b/src/main/java/ubc/pavlab/rdp/model/User.java index 30c1c0ae..f406c466 100644 --- a/src/main/java/ubc/pavlab/rdp/model/User.java +++ b/src/main/java/ubc/pavlab/rdp/model/User.java @@ -159,10 +159,13 @@ public boolean hasTaxon( @NonNull Taxon taxon ) { @JsonIgnore @Transient - public SortedSet getTaxons() { - return this.getUserGenes().values().stream().map( Gene::getTaxon ) - .sorted() // taxon are ordered by the ordering field - .collect( Collectors.toCollection( TreeSet::new ) ); + public Set getTaxons() { + return this.getUserGenes().values().stream() + .map( UserGene::getTaxon ) + // taxon are ordered by the ordering field, however the ordering field is not set for remote users + // because it is ignored in JSON serialization + .sorted( Comparator.comparing( Taxon::getOrdering, Comparator.nullsLast( Comparator.naturalOrder() ) ) ) + .collect( Collectors.toCollection( LinkedHashSet::new ) ); } /** From 37d6548bae5c0afc132629320c7862ffa6e9c16f Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 10:03:08 -0800 Subject: [PATCH 03/36] Add a test for viewing remote users with taxon --- .../rdp/controllers/SearchControllerTest.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java index 878f73e1..e3d5d691 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java @@ -1,10 +1,11 @@ package ubc.pavlab.rdp.controllers; +import com.fasterxml.jackson.databind.ObjectMapper; import org.assertj.core.util.Lists; +import org.hamcrest.Matchers; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -16,7 +17,6 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import ubc.pavlab.rdp.WebSecurityConfig; -import ubc.pavlab.rdp.events.OnRequestAccessEvent; import ubc.pavlab.rdp.exception.RemoteException; import ubc.pavlab.rdp.listeners.UserListener; import ubc.pavlab.rdp.model.*; @@ -27,6 +27,7 @@ import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.settings.SiteSettings; +import java.io.IOException; import java.net.URI; import java.util.Collections; import java.util.UUID; @@ -48,6 +49,9 @@ public class SearchControllerTest { @Autowired private MockMvc mvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean(name = "applicationSettings") private ApplicationSettings applicationSettings; @@ -203,6 +207,31 @@ public void viewUser_whenUserIsRemote_thenReturnSuccess() throws Exception { verify( remoteResourceService ).getRemoteUser( user.getId(), URI.create( "example.com" ) ); } + @Test + public void viewUser_whenUserIsRemoteAndHasTaxonWithoutOrdering_thenReturnSuccess() throws Exception { + Taxon humanTaxon = createTaxon( 9606 ); + Taxon mouseTaxon = createTaxon( 12145 ); + humanTaxon.setOrdering( 1 ); + mouseTaxon.setOrdering( 2 ); + Gene cdh1 = createGene( 1, humanTaxon ); + Gene brca1 = createGene( 2, mouseTaxon ); + User user = createUserWithGenes( 1, cdh1, brca1 ); + user.setOrigin( "Example Partner" ); + user.setOriginUrl( URI.create( "http://example.com/" ) ); + assertThat( user.getTaxons() ).hasSize( 2 ); + User remoteUser = remotify( user, User.class ); + assertThat( remoteUser.getTaxons() ).hasSize( 2 ).extracting( "ordering" ).containsOnly( (Integer) null ); + when( remoteResourceService.getRemoteUser( user.getId(), URI.create( "example.com" ) ) ).thenReturn( remoteUser ); + when( privacyService.checkCurrentUserCanSearch( true ) ).thenReturn( true ); + mvc.perform( get( "/userView/{userId}", user.getId() ) + .param( "remoteHost", "example.com" ) ) + .andExpect( status().isOk() ) + .andExpect( view().name( "userView" ) ) + .andExpect( model().attribute( "viewUser", Matchers.hasProperty( "taxons", + Matchers.everyItem( Matchers.hasProperty( "ordering", Matchers.nullValue() ) ) ) ) ); + verify( remoteResourceService ).getRemoteUser( user.getId(), URI.create( "example.com" ) ); + } + @Test public void viewUser_whenRemoteUserIsNotFound_thenReturnNotFound() throws Exception { when( remoteResourceService.getRemoteUser( 1, URI.create( "example.com" ) ) ).thenReturn( null ); @@ -381,5 +410,14 @@ public void requestAccess_whenUserHasPermission_thenRedirectToUserProfile() thro .andExpect( flash().attributeExists( "message" ) ); } - + /** + * Emulate the behaviour of an object retrieved from a partner API. + *

+ * Note: this is obviously incomplete, a better way to do this would be to inject an {@link ApiController}, but that + * is not appropriate for unit testing. Keep in mind that varying versions of the software will produce different + * serialization. + */ + private T remotify( Object object, Class objectClass ) throws IOException { + return objectMapper.readValue( objectMapper.writeValueAsBytes( object ), objectClass ); + } } From 97adc657ba51103cd6307fb2122a4ed28f7b768f Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 10:19:57 -0800 Subject: [PATCH 04/36] Fix all tests in SearchController to use remotified users --- .../rdp/controllers/SearchControllerTest.java | 21 +++++++++---------- .../java/ubc/pavlab/rdp/util/TestUtils.java | 6 ++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java index e3d5d691..40dedb69 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java @@ -197,8 +197,8 @@ public void viewUser_whenUserIsNotFound_thenReturnSuccess() throws Exception { @Test public void viewUser_whenUserIsRemote_thenReturnSuccess() throws Exception { - User user = createUser( 1 ); - when( remoteResourceService.getRemoteUser( user.getId(), URI.create( "example.com" ) ) ).thenReturn( user ); + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); + when( remoteResourceService.getRemoteUser( user.getId(), URI.create( "example.com" ) ) ).thenReturn( remotify( user, User.class ) ); when( privacyService.checkCurrentUserCanSearch( true ) ).thenReturn( true ); mvc.perform( get( "/userView/{userId}", user.getId() ) .param( "remoteHost", "example.com" ) ) @@ -216,8 +216,7 @@ public void viewUser_whenUserIsRemoteAndHasTaxonWithoutOrdering_thenReturnSucces Gene cdh1 = createGene( 1, humanTaxon ); Gene brca1 = createGene( 2, mouseTaxon ); User user = createUserWithGenes( 1, cdh1, brca1 ); - user.setOrigin( "Example Partner" ); - user.setOriginUrl( URI.create( "http://example.com/" ) ); + user.setOriginUrl( URI.create( "https://example.com/" ) ); assertThat( user.getTaxons() ).hasSize( 2 ); User remoteUser = remotify( user, User.class ); assertThat( remoteUser.getTaxons() ).hasSize( 2 ).extracting( "ordering" ).containsOnly( (Integer) null ); @@ -254,10 +253,10 @@ public void viewUser_whenRemoteIsUnavailable_thenReturnNotFound() throws Excepti @Test public void searchItlUsersByNameView_thenReturnSuccess() throws Exception { - User user = createUser( 1 ); + User user = createRemoteUser( 1, URI.create( "http://example.com/" ) ); when( permissionEvaluator.hasPermission( any(), isNull(), eq( "international-search" ) ) ).thenReturn( true ); when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) - .thenReturn( Collections.singleton( user ) ); + .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); mvc.perform( get( "/search/view/international" ) .param( "nameLike", "Mark" ) .param( "prefix", "true" ) ) @@ -267,7 +266,7 @@ public void searchItlUsersByNameView_thenReturnSuccess() throws Exception { @Test public void viewUser_whenRemoteUserCannotBeRetrieved_thenReturnNotFound() throws Exception { - User user = createUser( 1 ); + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); when( remoteResourceService.getRemoteUser( user.getId(), URI.create( "example.com" ) ) ).thenThrow( RemoteException.class ); when( privacyService.checkCurrentUserCanSearch( true ) ).thenReturn( true ); mvc.perform( get( "/userView/{userId}", user.getId() ) @@ -297,10 +296,10 @@ public void previewUser_whenUserProfileIsEmpty_thenReturnNoContent() throws Exce @Test public void previewUser_whenUserIsRemote_thenReturnSuccess() throws Exception { - User user = createUser( 1 ); + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); user.getProfile().setDescription( "This is a description." ); when( userService.findUserById( 1 ) ).thenReturn( user ); - when( remoteResourceService.getRemoteUser( 1, URI.create( "http://localhost/" ) ) ).thenReturn( user ); + when( remoteResourceService.getRemoteUser( 1, URI.create( "http://localhost/" ) ) ).thenReturn( remotify( user, User.class ) ); mvc.perform( get( "/search/view/user-preview/{userId}", user.getId() ) .param( "remoteHost", "http://localhost/" ) ) .andExpect( status().isOk() ); @@ -336,10 +335,10 @@ public void previewAnonymousUser_whenUserProfileIsEmpty_thenReturnNoContent() th @Test public void previewAnonymousUser_whenUserIsRemote_thenReturnSuccess() throws Exception { - User anonymousUser = createAnonymousUser(); + User anonymousUser = createAnonymousRemoteUser( URI.create( "http://example.com/" ) ); anonymousUser.getProfile().getResearcherCategories().add( ResearcherCategory.IN_SILICO ); when( remoteResourceService.getApiVersion( URI.create( "http://localhost/" ) ) ).thenReturn( "1.4.0" ); - when( remoteResourceService.getAnonymizedUser( anonymousUser.getAnonymousId(), URI.create( "http://localhost/" ) ) ).thenReturn( anonymousUser ); + when( remoteResourceService.getAnonymizedUser( anonymousUser.getAnonymousId(), URI.create( "http://localhost/" ) ) ).thenReturn( remotify( anonymousUser, User.class ) ); mvc.perform( get( "/search/view/user-preview/by-anonymous-id/{anonymousId}", anonymousUser.getAnonymousId() ) .param( "remoteHost", "http://localhost/" ) ) .andExpect( status().isOk() ) diff --git a/src/test/java/ubc/pavlab/rdp/util/TestUtils.java b/src/test/java/ubc/pavlab/rdp/util/TestUtils.java index 578f8dd2..ac88dc4e 100644 --- a/src/test/java/ubc/pavlab/rdp/util/TestUtils.java +++ b/src/test/java/ubc/pavlab/rdp/util/TestUtils.java @@ -106,6 +106,12 @@ public static User createAnonymousUser() { .build(); } + public static User createAnonymousRemoteUser( URI originUrl ) { + User user = createAnonymousUser(); + user.setOriginUrl( originUrl ); + return user; + } + @SneakyThrows public static Taxon createTaxon( int taxonId ) { return createTaxon( taxonId, TAXON_COMMON_NAME, TAXON_SCIENTIFIC_NAME, new URL( "ftp://ftp.ncbi.nlm.nih.gov/gene/DATA/GENE_INFO/Invertebrates/Caenorhabditis_elegans.gene_info.gz" ) ); From 266da45587b31f1a99d936f8f9639f2c4c049fe0 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 11:23:48 -0800 Subject: [PATCH 05/36] Add spring.datasource.driver-class-name in docs and exaple application.properties --- docs/installation.md | 1 + src/main/resources/application.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/installation.md b/docs/installation.md index 1696fc69..b9c3be7f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -42,6 +42,7 @@ file that contains at least the following entries: spring.profiles.active=prod spring.datasource.url=jdbc:mysql://:3306/ +spring.datasource-driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username= spring.datasource.password= diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e7642522..41e3555d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,6 +9,7 @@ spring.profiles.active=prod # 2) Creating a properties file elsewhere and setting spring.config.name and spring.config.location # # spring.datasource.url = jdbc:mysql://localhost:3306/{database_name} +# spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver # spring.datasource.username = {username} # spring.datasource.password = {password} From 5f1e7eb6bb77e36a12a902c4353f3909e4eddced Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 11:13:20 -0800 Subject: [PATCH 06/36] Improve cache names with namespace and cleaner keys Add a namespace to the 'stats' cache. Remove @Cacheable on UserRepository.count() in favour of UserService.countResearchers(). Add a specialized cache for storing partner API versions. --- .../ubc/pavlab/rdp/repositories/UserRepository.java | 1 - .../ubc/pavlab/rdp/services/PrivacyServiceImpl.java | 4 ++-- .../rdp/services/RemoteResourceServiceImpl.java | 2 +- .../ubc/pavlab/rdp/services/UserGeneServiceImpl.java | 12 ++++++------ .../ubc/pavlab/rdp/services/UserServiceImpl.java | 2 ++ src/main/resources/ehcache.xml | 10 +++++++++- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java index fff0deb0..b32c4f0d 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java @@ -25,7 +25,6 @@ public interface UserRepository extends JpaRepository { @SuppressWarnings("SpringCacheAnnotationsOnInterfaceInspection") @Override - @Cacheable(cacheNames = "stats", key = "#root.methodName") long count(); long countByProfilePrivacyLevel( PrivacyLevelType aPublic ); diff --git a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java index f870d9ed..c080a278 100644 --- a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java @@ -104,12 +104,12 @@ private boolean checkUserCanSeeOtherUserContentWithPrivacyLevel( User currentUse || ( profile.isShared() && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && currentUser.getId().equals( applicationSettings.getIsearch().getUserId() ) ); // data is designated as remotely shared and there is an admin logged in who is the remote admin } - @Cacheable + @Cacheable(value = "ubc.pavlab.rdp.model.Role.byRole", key = "'ROLE_ADMIN'") public Role getAdminRole() { return roleRepository.findByRole( "ROLE_ADMIN" ); } - @Cacheable + @Cacheable(value = "ubc.pavlab.rdp.model.Role.byRole", key = "'ROLE_SERVICE_ACCOUNT'") public Role getServiceAccountRole() { return roleRepository.findByRole( "ROLE_SERVICE_ACCOUNT" ); } diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 4adce6ac..92b05842 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -58,7 +58,7 @@ public class RemoteResourceServiceImpl implements RemoteResourceService { private AsyncRestTemplate asyncRestTemplate; @Override - @Cacheable + @Cacheable(value = "ubc.pavlab.rdp.services.RemoteResourceService.apiVersionByRemoteHostAuthority", key = "#remoteHost.authority") public String getApiVersion( URI remoteHost ) throws RemoteException { // Ensure that the remoteHost is one of our known APIs by comparing the URI authority component and always use // the URI defined in the configuration diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 26bc22ce..22ea258b 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -81,32 +81,32 @@ public Page findAllByPrivacyLevel( PrivacyLevelType privacyLevelType, return userGeneRepository.findAllByPrivacyLevelAndUserProfilePrivacyLevel( privacyLevelType, pageable ); } - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Integer countUniqueAssociations() { return userGeneRepository.countDistinctGeneByTierIn( TierType.MANUAL ); } - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Integer countAssociations() { return userGeneRepository.countByTierIn( TierType.MANUAL ); } - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Map researcherCountByTaxon() { return taxonRepository.findByActiveTrueOrderByOrdering().stream() .collect( Collectors.toMap( Taxon::getCommonName, userGeneRepository::countDistinctUserByTaxon ) ); } - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Integer countUsersWithGenes() { return userGeneRepository.countDistinctUser(); } - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Integer countUniqueAssociationsAllTiers() { return userGeneRepository.countDistinctGeneByTierIn( tierService.getEnabledTiers() ); @@ -118,7 +118,7 @@ public Integer countUniqueAssociationsAllTiers() { *

* This is also known as the "human gene coverage". */ - @Cacheable(cacheNames = "stats", key = "#root.methodName") + @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") @Override public Integer countUniqueAssociationsToHumanAllTiers() { Collection humanGenes = new HashSet<>( userGeneRepository.findAllHumanGenes() ); diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 366df538..54139c75 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -5,6 +5,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.MessageSource; import org.springframework.data.domain.Page; @@ -360,6 +361,7 @@ public Collection findByDescription( String descriptionLike, Set + + @@ -47,7 +51,11 @@ timeToLiveSeconds="3600" memoryStoreEvictionPolicy="FIFO"/> - + + \ No newline at end of file From c98b283661adc5baaa3e08ff362d025c270a6d6c Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 12:25:26 -0800 Subject: [PATCH 07/36] Handle missing routes with an ApiException (fix #126) It's not exactly a friendly JSON response at this stage, but that will have to wait until the next minor release. --- .../ubc/pavlab/rdp/controllers/ApiController.java | 13 ++++++++++++- .../pavlab/rdp/controllers/ApiControllerTest.java | 7 +++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 77934bae..66ba2832 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -67,7 +67,18 @@ public ResponseEntity handleAuthenticationException() { @ExceptionHandler({ ApiException.class }) public ResponseEntity handleApiException( ApiException e ) { - return ResponseEntity.status( e.getStatus() ).body( e.getMessage() ); + return ResponseEntity + .status( e.getStatus() ) + .contentType( MediaType.TEXT_PLAIN ) + .body( e.getMessage() ); + } + + /** + * Handle all unmapped API requests with a 404 error. + */ + @RequestMapping(value = "/api/*") + public void handleMissingRoute() { + throw new ApiException( HttpStatus.NOT_FOUND, "No endpoint found for your request URL." ); } /** diff --git a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java index 70a25949..cf9c8be3 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java @@ -84,6 +84,13 @@ public void setUp() { when( messageSource.getMessage( eq( "rdp.site.shortname" ), any(), any() ) ).thenReturn( "RDMM" ); } + @Test + public void accessUnmappedRoute_thenReturn404() throws Exception { + mvc.perform( get( "/api/notfound" ) ) + .andExpect( status().isNotFound() ) + .andExpect( content().contentType( MediaType.TEXT_PLAIN ) ); + } + @Test public void searchGenes_withSearchDisabled_thenReturnServiceUnavailable() throws Exception { when( iSearchSettings.isEnabled() ).thenReturn( false ); From dca11d24c737039b8b06b1bd9a0f9044bd3c031a Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 13:39:20 -0800 Subject: [PATCH 08/36] Fix NullPointerException when orthologTaxonId does not refer to an existing taxon --- .../rdp/controllers/SearchController.java | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java index 4d1021fa..35f447d6 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java @@ -232,11 +232,8 @@ public ModelAndView searchUsersByGene( @RequestParam String symbol, .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) .collect( Collectors.toSet() ); - if ( - // Check if there is a ortholog request for a different taxon than the original gene - ( orthologTaxonId != null && !orthologTaxonId.equals( gene.getTaxon().getId() ) ) && - // Check if we got some ortholog results - ( orthologs == null || orthologs.isEmpty() ) ) { + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { modelAndView.setStatus( HttpStatus.NOT_FOUND ); modelAndView.addObject( "message", messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); @@ -303,11 +300,8 @@ public ModelAndView searchUsersByGeneView( @RequestParam String symbol, .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) .collect( Collectors.toSet() ); - if ( - // Check if there is a ortholog request for a different taxon than the original gene - ( orthologTaxonId != null && !orthologTaxonId.equals( gene.getTaxon().getId() ) ) - // Check if we got some ortholog results - && orthologs.isEmpty() ) { + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { modelAndView.setViewName( "fragments/error::message" ); modelAndView.addObject( "errorMessage", messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); @@ -364,11 +358,8 @@ public ModelAndView searchOrthologsForGene( @RequestParam String symbol, .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) .collect( Collectors.toSet() ); - if ( - // Check if there is a ortholog request for a different taxon than the original gene - ( orthologTaxonId != null && !orthologTaxonId.equals( gene.getTaxon().getId() ) ) - // Check if we got some ortholog results - && orthologs.isEmpty() ) { + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { modelAndView.setStatus( HttpStatus.BAD_REQUEST ); modelAndView.setViewName( "fragments/error::message" ); modelAndView.addObject( "errorMessage", From ebbf20bf38960a33683fd4eae14a46741dd3512e Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 14:11:29 -0800 Subject: [PATCH 09/36] Fix SonarQube warnings for missing assertions in tests --- .../rdp/repositories/UserRepositoryTest.java | 2 ++ .../rdp/services/UserServiceImplTest.java | 29 +++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java b/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java index 3c8dea10..46db7fa8 100644 --- a/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java +++ b/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java @@ -478,6 +478,8 @@ public void save_whenUserHasMultipleResearcherCategories_thenSucceed() { user.getProfile().getResearcherCategories().add( ResearcherCategory.IN_SILICO ); user.getProfile().getResearcherCategories().add( ResearcherCategory.IN_VIVO ); user = entityManager.persistAndFlush( user ); + assertThat( user.getProfile().getResearcherCategories() ) + .containsExactly( ResearcherCategory.IN_SILICO, ResearcherCategory.IN_VIVO ); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java index fff2dacb..4261870c 100644 --- a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java @@ -156,7 +156,7 @@ public void setUp() { when( applicationSettings.getPrivacy() ).thenReturn( privacySettings ); when( applicationSettings.getProfile() ).thenReturn( profileSettings ); - when( geneInfoService.load( anyCollection() ) ).thenAnswer( + when( geneInfoService.load( anyCollectionOf( Integer.class ) ) ).thenAnswer( a -> a.getArgumentAt( 0, Collection.class ).stream() .map( o -> geneInfoService.load( (Integer) o ) ) .filter( Objects::nonNull ) @@ -229,9 +229,7 @@ private void setUpRecommendTermsMocks() { termFrequencies.put( t99, 1L ); when( goService.getDescendants( t98 ) ).thenReturn( Collections.singleton( t99 ) ); - termFrequencies.forEach( ( key, value ) -> { - when( goService.getSizeInTaxon( key, taxon ) ).thenReturn( value + 9 ); - } ); + termFrequencies.forEach( ( key, value ) -> when( goService.getSizeInTaxon( key, taxon ) ).thenReturn( value + 9 ) ); Map termMap = termFrequencies.keySet().stream() .collect( Collectors.toMap( GeneOntologyTerm::getGoId, Function.identity() ) ); @@ -551,16 +549,29 @@ public void updateUserProfileAndPublications_whenPublications_thenReplaceAll() { @Test public void updateUserProfileAndPublication_whenOrgansIsEnabled_thenSaveOrgans() { + when( applicationSettings.getOrgans().getEnabled() ).thenReturn( true ); User user = createUser( 1 ); - userService.updateUserProfileAndPublicationsAndOrgans( user, user.getProfile(), null, null, Locale.getDefault() ); - // assertThat( user.getUserOrgans() ).containsValue( userOrgan ); + //noinspection unchecked + when( organInfoService.findByUberonIdIn( anyCollectionOf( String.class ) ) ) + .thenAnswer( a -> ( (Collection) a.getArgumentAt( 0, Collection.class ) ).stream() + .map( id -> createOrgan( id, null, null ) ) + .collect( Collectors.toSet() ) ); + Set organUberonIds = Sets.newSet( "UBERON:00001", "UBERON:00002" ); + user = userService.updateUserProfileAndPublicationsAndOrgans( user, user.getProfile(), null, organUberonIds, Locale.getDefault() ); + verify( organInfoService ).findByUberonIdIn( organUberonIds ); + assertThat( user.getUserOrgans().values() ) + .extracting( "uberonId" ) + .containsExactlyInAnyOrder( "UBERON:00001", "UBERON:00002" ); } @Test public void updateUserProfileAndPublication_whenOrgansIsNotEnabled_thenIgnoreOrgans() { - when( applicationSettings.getOrgans().getEnabled() ).thenReturn( true ); + when( applicationSettings.getOrgans().getEnabled() ).thenReturn( false ); User user = createUser( 1 ); - userService.updateUserProfileAndPublicationsAndOrgans( user, user.getProfile(), null, null, Locale.getDefault() ); + Set organUberonIds = Sets.newSet( "UBERON:00001", "UBERON:00002" ); + user = userService.updateUserProfileAndPublicationsAndOrgans( user, user.getProfile(), null, organUberonIds, Locale.getDefault() ); + verifyZeroInteractions( organInfoService ); + assertThat( user.getUserOrgans() ).isEmpty(); } @Test @@ -683,7 +694,7 @@ public void updateUserProfileAndPublicationsAndOrgans_whenResearcherCategoriesAr } @Test - public void createPasswordResetTokenForUser_hasCorrectExpiration() throws MessagingException { + public void createPasswordResetTokenForUser_hasCorrectExpiration() { User user = createUser( 1 ); PasswordResetToken passwordResetToken = userService.createPasswordResetTokenForUser( user, Locale.getDefault() ); From e6ebf097a6ac2236610a79733a2265f63bb45b24 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 14:38:55 -0800 Subject: [PATCH 10/36] Handle AccessDeniedException in search views (fix #142) Unfortunately, Spring does not support handlers for specific methods, so we had to split all the search templates from the search views. In a sense, it's a good thing as it provides a better code organization. --- .../rdp/controllers/SearchController.java | 285 +-------------- .../rdp/controllers/SearchViewController.java | 346 ++++++++++++++++++ .../rdp/controllers/SearchControllerTest.java | 30 +- 3 files changed, 377 insertions(+), 284 deletions(-) create mode 100644 src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java index 35f447d6..9f177922 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; @@ -13,10 +14,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -101,37 +99,6 @@ public ModelAndView searchUsersByName( @RequestParam String nameLike, return modelAndView; } - @PreAuthorize("hasPermission(null, 'search')") - @GetMapping(value = "/search/view", params = { "nameLike" }) - public ModelAndView searchUsersByNameView( @RequestParam String nameLike, - @RequestParam Boolean prefix, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds ) { - Collection users; - if ( prefix ) { - users = userService.findByStartsName( nameLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ); - } else { - users = userService.findByLikeName( nameLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ); - } - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); - modelAndView.addObject( "users", users ); - return modelAndView; - } - - @PreAuthorize("hasPermission(null, 'international-search')") - @GetMapping(value = "/search/view/international", params = { "nameLike" }) - public ModelAndView searchItlUsersByNameView( @RequestParam String nameLike, - @RequestParam Boolean prefix, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds ) { - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); - modelAndView.addObject( "users", remoteResourceService.findUsersByLikeName( nameLike, prefix, researcherPositions, researcherCategories, organUberonIds ) ); - modelAndView.addObject( "remote", Boolean.TRUE ); - return modelAndView; - } - @PreAuthorize("hasPermission(null, #iSearch ? 'international-search' : 'search')") @GetMapping(value = "/search", params = { "descriptionLike" }) public ModelAndView searchUsersByDescription( @RequestParam String descriptionLike, @@ -154,29 +121,6 @@ public ModelAndView searchUsersByDescription( @RequestParam String descriptionLi return modelAndView; } - @PreAuthorize("hasPermission(null, 'search')") - @GetMapping(value = "/search/view", params = { "descriptionLike" }) - public ModelAndView searchUsersByDescriptionView( @RequestParam String descriptionLike, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds ) { - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); - modelAndView.addObject( "users", userService.findByDescription( descriptionLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ) ); - return modelAndView; - } - - @PreAuthorize("hasPermission(null, 'international-search')") - @GetMapping(value = "/search/view/international", params = { "descriptionLike" }) - public ModelAndView searchItlUsersByDescriptionView( @RequestParam String descriptionLike, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds ) { - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); - modelAndView.addObject( "users", remoteResourceService.findUsersByDescription( descriptionLike, researcherPositions, researcherCategories, organUberonIds ) ); - modelAndView.addObject( "remote", Boolean.TRUE ); - return modelAndView; - } - @PreAuthorize("hasPermission(null, 'search')") @GetMapping(value = "/search", params = { "symbol", "taxonId" }) public ModelAndView searchUsersByGene( @RequestParam String symbol, @@ -249,230 +193,6 @@ public ModelAndView searchUsersByGene( @RequestParam String symbol, return modelAndView; } - @PreAuthorize("hasPermission(null, 'search')") - @GetMapping(value = "/search/view") - public ModelAndView searchUsersByGeneView( @RequestParam String symbol, - @RequestParam Integer taxonId, - @RequestParam(required = false) Set tiers, - @RequestParam(required = false) Integer orthologTaxonId, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds, - Locale locale ) { - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::usergenes-table" ); - - if ( tiers == null ) { - tiers = TierType.ANY; - } - - // Only look for orthologs when taxon is human - if ( taxonId != 9606 ) { - orthologTaxonId = null; - } - - Taxon taxon = taxonService.findById( taxonId ); - - if ( taxon == null ) { - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoTaxon", new String[]{ taxonId.toString() }, locale ) ); - return modelAndView; - } - - GeneInfo gene = geneInfoService.findBySymbolAndTaxon( symbol, taxon ); - - if ( gene == null ) { - modelAndView.setStatus( HttpStatus.NOT_FOUND ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoGene", new String[]{ symbol, taxon.getScientificName() }, locale ) ); - return modelAndView; - } - - Taxon orthologTaxon = orthologTaxonId == null ? null : taxonService.findById( orthologTaxonId ); - if ( orthologTaxonId != null && orthologTaxon == null ) { - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", messageSource.getMessage( "SearchController.errorNoOrthologTaxonId", new String[]{ orthologTaxonId.toString() }, locale ) ); - return modelAndView; - } - - Collection orthologs = gene.getOrthologs().stream() - .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) - .collect( Collectors.toSet() ); - - // Check if there is an ortholog request for a different taxon than the original gene - if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); - return modelAndView; - } - - modelAndView.addObject( "usergenes", userGeneService.handleGeneSearch( gene, tiers, orthologTaxon, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ) ); - - return modelAndView; - } - - @PreAuthorize("hasPermission(null, 'search')") - @GetMapping(value = "/search/view/orthologs") - public ModelAndView searchOrthologsForGene( @RequestParam String symbol, - @RequestParam Integer taxonId, - @RequestParam(required = false) Set tiers, - @RequestParam(required = false) Integer orthologTaxonId, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds, - Locale locale ) { - ModelAndView modelAndView = new ModelAndView( "fragments/ortholog-table::ortholog-table" ); - - // Only look for orthologs when taxon is human - if ( taxonId != 9606 ) { - orthologTaxonId = null; - } - - if ( tiers == null ) { - tiers = TierType.ANY; - } - - Taxon taxon = taxonService.findById( taxonId ); - if ( taxon == null ) { - modelAndView.setStatus( HttpStatus.BAD_REQUEST ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoTaxon", new String[]{ taxonId.toString() }, locale ) ); - return modelAndView; - } - - GeneInfo gene = geneInfoService.findBySymbolAndTaxon( symbol, taxon ); - - if ( gene == null ) { - modelAndView.setStatus( HttpStatus.NOT_FOUND ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoGene", new String[]{ symbol, taxon.getScientificName() }, locale ) ); - return modelAndView; - } - - Taxon orthologTaxon = orthologTaxonId == null ? null : taxonService.findById( orthologTaxonId ); - Collection orthologs = gene.getOrthologs().stream() - .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) - .collect( Collectors.toSet() ); - - // Check if there is an ortholog request for a different taxon than the original gene - if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { - modelAndView.setStatus( HttpStatus.BAD_REQUEST ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", - messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); - return modelAndView; - } - - SortedMap> orthologMap = orthologs.stream() - .collect( Collectors.groupingBy( GeneInfo::getTaxon, TreeMap::new, Collectors.toSet() ) ); - - modelAndView.addObject( "orthologs", orthologMap ); - - return modelAndView; - } - - @PreAuthorize("hasPermission(null, 'international-search')") - @GetMapping(value = "/search/view/international", params = { "symbol", "taxonId", "orthologTaxonId" }) - public ModelAndView searchItlUsersByGeneView( @RequestParam String symbol, - @RequestParam Integer taxonId, - @RequestParam(required = false) Set tiers, - @RequestParam(required = false) Integer orthologTaxonId, - @RequestParam(required = false) Set researcherPositions, - @RequestParam(required = false) Set researcherCategories, - @RequestParam(required = false) Set organUberonIds ) { - // Only look for orthologs when taxon is human - if ( taxonId != 9606 ) { - orthologTaxonId = null; - } - - if ( tiers == null ) { - tiers = TierType.ANY; - } - - Taxon taxon = taxonService.findById( taxonId ); - Collection userGenes = remoteResourceService.findGenesBySymbol( symbol, taxon, tiers, orthologTaxonId, researcherPositions, researcherCategories, organUberonIds ); - - ModelAndView modelAndView = new ModelAndView( "fragments/user-table::usergenes-table" ); - modelAndView.addObject( "usergenes", userGenes ); - modelAndView.addObject( "remote", Boolean.TRUE ); - - return modelAndView; - } - - @GetMapping(value = "/search/view/user-preview/{userId}") - public ModelAndView previewUser( @PathVariable Integer userId, - @RequestParam(required = false) String remoteHost ) { - ModelAndView modelAndView = new ModelAndView( "fragments/profile::user-preview" ); - - User user; - if ( remoteHost != null ) { - try { - user = remoteResourceService.getRemoteUser( userId, URI.create( remoteHost ) ); - } catch ( RemoteException e ) { - log.error( "Could not retrieve user {0} from {1}.", e ); - modelAndView.setStatus( HttpStatus.INTERNAL_SERVER_ERROR ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", "Error querying remote user." ); - return modelAndView; - } - } else { - user = userService.findUserById( userId ); - } - if ( user == null ) { - modelAndView.setStatus( HttpStatus.NOT_FOUND ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", "Could not find user by given identifier." ); - } else if ( userPreviewIsEmpty( user ) ) { - modelAndView.setStatus( HttpStatus.NO_CONTENT ); - modelAndView.addObject( "user", user ); - } else { - modelAndView.addObject( "user", user ); - } - return modelAndView; - } - - @GetMapping(value = "/search/view/user-preview/by-anonymous-id/{anonymousId}") - public ModelAndView previewAnonymousUser( @PathVariable UUID anonymousId, - @RequestParam(required = false) String remoteHost ) { - ModelAndView modelAndView = new ModelAndView( "fragments/profile::user-preview" ); - User user; - if ( remoteHost != null ) { - URI remoteHostUri = URI.create( remoteHost ); - try { - user = remoteResourceService.getAnonymizedUser( anonymousId, remoteHostUri ); - } catch ( RemoteException e ) { - log.error( MessageFormat.format( "Failed to retrieve anonymized user {} from {}.", anonymousId, remoteHostUri.getAuthority() ), e ); - modelAndView.setStatus( HttpStatus.INTERNAL_SERVER_ERROR ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", "Error querying remote anonymous user." ); - return modelAndView; - } - } else { - user = userService.anonymizeUser( userService.findUserByAnonymousIdNoAuth( anonymousId ) ); - } - if ( user == null ) { - modelAndView.setStatus( HttpStatus.NOT_FOUND ); - modelAndView.setViewName( "fragments/error::message" ); - modelAndView.addObject( "errorMessage", "Could not find user by given identifier." ); - } else if ( userPreviewIsEmpty( user ) ) { - modelAndView.setStatus( HttpStatus.NO_CONTENT ); - modelAndView.addObject( "user", user ); - } else { - modelAndView.addObject( "user", user ); - } - return modelAndView; - } - - private boolean userPreviewIsEmpty( User user ) { - return ( user.getProfile().getDescription() == null || user.getProfile().getDescription().isEmpty() ) && - user.getProfile().getResearcherCategories().isEmpty() && - user.getUserOrgans().isEmpty(); - } - @GetMapping(value = "/userView/{userId}") public ModelAndView viewUser( @PathVariable Integer userId, @RequestParam(required = false) String remoteHost ) { @@ -564,5 +284,4 @@ public ModelAndView requestGeneAccess( @PathVariable UUID anonymousId, private Collection organsFromUberonIds( Set organUberonIds ) { return organUberonIds == null ? null : userOrganService.findByUberonIdIn( organUberonIds ); } - } diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java new file mode 100644 index 00000000..70aba4bb --- /dev/null +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java @@ -0,0 +1,346 @@ +package ubc.pavlab.rdp.controllers; + +import lombok.extern.apachecommons.CommonsLog; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; +import ubc.pavlab.rdp.exception.RemoteException; +import ubc.pavlab.rdp.model.*; +import ubc.pavlab.rdp.model.enums.ResearcherCategory; +import ubc.pavlab.rdp.model.enums.ResearcherPosition; +import ubc.pavlab.rdp.model.enums.TierType; +import ubc.pavlab.rdp.services.*; + +import java.net.URI; +import java.text.MessageFormat; +import java.util.*; +import java.util.stream.Collectors; + +@Controller +@CommonsLog +public class SearchViewController { + + @Autowired + private MessageSource messageSource; + + @Autowired + private UserService userService; + + @Autowired + private TaxonService taxonService; + + @Autowired + private UserGeneService userGeneService; + + @Autowired + private UserOrganService userOrganService; + + @Autowired + private RemoteResourceService remoteResourceService; + + @Autowired + private GeneInfoService geneInfoService; + + @ExceptionHandler({ AccessDeniedException.class }) + public ModelAndView handleAccessDeniedForViewTemplates( AccessDeniedException exception ) { + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setStatus( HttpStatus.UNAUTHORIZED ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", exception.getMessage() ); + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'international-search')") + @GetMapping(value = "/search/view/international", params = { "nameLike" }) + public ModelAndView searchItlUsersByNameView( @RequestParam String nameLike, + @RequestParam Boolean prefix, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds ) { + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); + modelAndView.addObject( "users", remoteResourceService.findUsersByLikeName( nameLike, prefix, researcherPositions, researcherCategories, organUberonIds ) ); + modelAndView.addObject( "remote", Boolean.TRUE ); + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'search')") + @GetMapping(value = "/search/view", params = { "nameLike" }) + public ModelAndView searchUsersByNameView( @RequestParam String nameLike, + @RequestParam Boolean prefix, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds ) { + Collection users; + if ( prefix ) { + users = userService.findByStartsName( nameLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ); + } else { + users = userService.findByLikeName( nameLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ); + } + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); + modelAndView.addObject( "users", users ); + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'search')") + @GetMapping(value = "/search/view", params = { "descriptionLike" }) + public ModelAndView searchUsersByDescriptionView( @RequestParam String descriptionLike, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds ) { + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); + modelAndView.addObject( "users", userService.findByDescription( descriptionLike, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ) ); + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'search')") + @GetMapping(value = "/search/view") + public ModelAndView searchUsersByGeneView( @RequestParam String symbol, + @RequestParam Integer taxonId, + @RequestParam(required = false) Set tiers, + @RequestParam(required = false) Integer orthologTaxonId, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds, + Locale locale ) { + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::usergenes-table" ); + + if ( tiers == null ) { + tiers = TierType.ANY; + } + + // Only look for orthologs when taxon is human + if ( taxonId != 9606 ) { + orthologTaxonId = null; + } + + Taxon taxon = taxonService.findById( taxonId ); + + if ( taxon == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoTaxon", new String[]{ taxonId.toString() }, locale ) ); + return modelAndView; + } + + GeneInfo gene = geneInfoService.findBySymbolAndTaxon( symbol, taxon ); + + if ( gene == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoGene", new String[]{ symbol, taxon.getScientificName() }, locale ) ); + return modelAndView; + } + + Taxon orthologTaxon = orthologTaxonId == null ? null : taxonService.findById( orthologTaxonId ); + if ( orthologTaxonId != null && orthologTaxon == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", messageSource.getMessage( "SearchController.errorNoOrthologTaxonId", new String[]{ orthologTaxonId.toString() }, locale ) ); + return modelAndView; + } + + Collection orthologs = gene.getOrthologs().stream() + .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) + .collect( Collectors.toSet() ); + + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); + return modelAndView; + } + + modelAndView.addObject( "usergenes", userGeneService.handleGeneSearch( gene, tiers, orthologTaxon, researcherPositions, researcherCategories, organsFromUberonIds( organUberonIds ) ) ); + + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'search')") + @GetMapping(value = "/search/view/orthologs") + public ModelAndView searchOrthologsForGene( @RequestParam String symbol, + @RequestParam Integer taxonId, + @RequestParam(required = false) Set tiers, + @RequestParam(required = false) Integer orthologTaxonId, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds, + Locale locale ) { + ModelAndView modelAndView = new ModelAndView( "fragments/ortholog-table::ortholog-table" ); + + // Only look for orthologs when taxon is human + if ( taxonId != 9606 ) { + orthologTaxonId = null; + } + + if ( tiers == null ) { + tiers = TierType.ANY; + } + + Taxon taxon = taxonService.findById( taxonId ); + if ( taxon == null ) { + modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoTaxon", new String[]{ taxonId.toString() }, locale ) ); + return modelAndView; + } + + GeneInfo gene = geneInfoService.findBySymbolAndTaxon( symbol, taxon ); + + if ( gene == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoGene", new String[]{ symbol, taxon.getScientificName() }, locale ) ); + return modelAndView; + } + + Taxon orthologTaxon = orthologTaxonId == null ? null : taxonService.findById( orthologTaxonId ); + Collection orthologs = gene.getOrthologs().stream() + .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) + .collect( Collectors.toSet() ); + + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { + modelAndView.setStatus( HttpStatus.BAD_REQUEST ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", + messageSource.getMessage( "SearchController.errorNoOrthologs", new String[]{ symbol, orthologTaxon.getScientificName() }, locale ) ); + return modelAndView; + } + + SortedMap> orthologMap = orthologs.stream() + .collect( Collectors.groupingBy( GeneInfo::getTaxon, TreeMap::new, Collectors.toSet() ) ); + + modelAndView.addObject( "orthologs", orthologMap ); + + return modelAndView; + } + + @PreAuthorize("hasPermission(null, 'international-search')") + @GetMapping(value = "/search/view/international", params = { "descriptionLike" }) + public ModelAndView searchItlUsersByDescriptionView( @RequestParam String descriptionLike, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds ) { + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::user-table" ); + modelAndView.addObject( "users", remoteResourceService.findUsersByDescription( descriptionLike, researcherPositions, researcherCategories, organUberonIds ) ); + modelAndView.addObject( "remote", Boolean.TRUE ); + return modelAndView; + } + + + @PreAuthorize("hasPermission(null, 'international-search')") + @GetMapping(value = "/search/view/international", params = { "symbol", "taxonId", "orthologTaxonId" }) + public ModelAndView searchItlUsersByGeneView( @RequestParam String symbol, + @RequestParam Integer taxonId, + @RequestParam(required = false) Set tiers, + @RequestParam(required = false) Integer orthologTaxonId, + @RequestParam(required = false) Set researcherPositions, + @RequestParam(required = false) Set researcherCategories, + @RequestParam(required = false) Set organUberonIds ) { + // Only look for orthologs when taxon is human + if ( taxonId != 9606 ) { + orthologTaxonId = null; + } + + if ( tiers == null ) { + tiers = TierType.ANY; + } + + Taxon taxon = taxonService.findById( taxonId ); + Collection userGenes = remoteResourceService.findGenesBySymbol( symbol, taxon, tiers, orthologTaxonId, researcherPositions, researcherCategories, organUberonIds ); + + ModelAndView modelAndView = new ModelAndView( "fragments/user-table::usergenes-table" ); + modelAndView.addObject( "usergenes", userGenes ); + modelAndView.addObject( "remote", Boolean.TRUE ); + + return modelAndView; + } + + @GetMapping(value = "/search/view/user-preview/{userId}") + public ModelAndView previewUser( @PathVariable Integer userId, + @RequestParam(required = false) String remoteHost ) { + ModelAndView modelAndView = new ModelAndView( "fragments/profile::user-preview" ); + + User user; + if ( remoteHost != null ) { + try { + user = remoteResourceService.getRemoteUser( userId, URI.create( remoteHost ) ); + } catch ( RemoteException e ) { + log.error( "Could not retrieve user {0} from {1}.", e ); + modelAndView.setStatus( HttpStatus.INTERNAL_SERVER_ERROR ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", "Error querying remote user." ); + return modelAndView; + } + } else { + user = userService.findUserById( userId ); + } + if ( user == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", "Could not find user by given identifier." ); + } else if ( userPreviewIsEmpty( user ) ) { + modelAndView.setStatus( HttpStatus.NO_CONTENT ); + modelAndView.addObject( "user", user ); + } else { + modelAndView.addObject( "user", user ); + } + return modelAndView; + } + + @GetMapping(value = "/search/view/user-preview/by-anonymous-id/{anonymousId}") + public ModelAndView previewAnonymousUser( @PathVariable UUID anonymousId, + @RequestParam(required = false) String remoteHost ) { + ModelAndView modelAndView = new ModelAndView( "fragments/profile::user-preview" ); + User user; + if ( remoteHost != null ) { + URI remoteHostUri = URI.create( remoteHost ); + try { + user = remoteResourceService.getAnonymizedUser( anonymousId, remoteHostUri ); + } catch ( RemoteException e ) { + log.error( MessageFormat.format( "Failed to retrieve anonymized user {} from {}.", anonymousId, remoteHostUri.getAuthority() ), e ); + modelAndView.setStatus( HttpStatus.INTERNAL_SERVER_ERROR ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", "Error querying remote anonymous user." ); + return modelAndView; + } + } else { + user = userService.anonymizeUser( userService.findUserByAnonymousIdNoAuth( anonymousId ) ); + } + if ( user == null ) { + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", "Could not find user by given identifier." ); + } else if ( userPreviewIsEmpty( user ) ) { + modelAndView.setStatus( HttpStatus.NO_CONTENT ); + modelAndView.addObject( "user", user ); + } else { + modelAndView.addObject( "user", user ); + } + return modelAndView; + } + + private boolean userPreviewIsEmpty( User user ) { + return ( user.getProfile().getDescription() == null || user.getProfile().getDescription().isEmpty() ) && + user.getProfile().getResearcherCategories().isEmpty() && + user.getUserOrgans().isEmpty(); + } + + private Collection organsFromUberonIds( Set organUberonIds ) { + return organUberonIds == null ? null : userOrganService.findByUberonIdIn( organUberonIds ); + } +} diff --git a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java index 40dedb69..fbe312c3 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java @@ -42,7 +42,7 @@ import static ubc.pavlab.rdp.util.TestUtils.*; @RunWith(SpringRunner.class) -@WebMvcTest(SearchController.class) +@WebMvcTest({ SearchController.class, SearchViewController.class }) @Import(WebSecurityConfig.class) public class SearchControllerTest { @@ -251,6 +251,34 @@ public void viewUser_whenRemoteIsUnavailable_thenReturnNotFound() throws Excepti verify( remoteResourceService ).getRemoteUser( 1, URI.create( "example.com" ) ); } + @Test + public void searchUsersByNameView_thenReturnSuccess() throws Exception { + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); + when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); + when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) + .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); + mvc.perform( get( "/search/view" ) + .param( "nameLike", "Mark" ) + .param( "prefix", "true" ) ) + .andExpect( status().isOk() ) + .andExpect( view().name( "fragments/user-table::user-table" ) ); + } + + @Test + public void searchUsersByNameView_whenSearchIsUnavailable_thenReturnUnauthorized() throws Exception { + // The frontend cannot handle 3xx redirection to the login page as that would return a full-fledged HTML + // document, so instead it must produce a 401 Not Authorized exception + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); + when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( false ); + when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) + .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); + mvc.perform( get( "/search/view" ) + .param( "nameLike", "Mark" ) + .param( "prefix", "true" ) ) + .andExpect( status().isUnauthorized() ) + .andExpect( view().name( "fragments/error::message" ) ); + } + @Test public void searchItlUsersByNameView_thenReturnSuccess() throws Exception { User user = createRemoteUser( 1, URI.create( "http://example.com/" ) ); From c98c58cf87f12a5f39c70a3612a75ed8f09bd478 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Wed, 9 Feb 2022 15:59:12 -0800 Subject: [PATCH 11/36] Add @PreAuthorize for all routes in SearchController and SearchViewController --- .../ubc/pavlab/rdp/controllers/SearchController.java | 11 +++++++---- .../pavlab/rdp/controllers/SearchViewController.java | 2 ++ .../pavlab/rdp/controllers/SearchControllerTest.java | 10 +++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java index 9f177922..b02e31d2 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java @@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; -import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; @@ -14,7 +13,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -193,6 +195,7 @@ public ModelAndView searchUsersByGene( @RequestParam String symbol, return modelAndView; } + @PreAuthorize("hasPermission(null, #remoteHost == null ? 'search' : 'international-search')") @GetMapping(value = "/userView/{userId}") public ModelAndView viewUser( @PathVariable Integer userId, @RequestParam(required = false) String remoteHost ) { @@ -230,7 +233,7 @@ private static class RequestAccessForm { private String reason; } - @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @PreAuthorize("hasPermission(null, 'search') and hasAnyRole('USER', 'ADMIN')") @GetMapping("/search/gene/by-anonymous-id/{anonymousId}/request-access") public Object requestGeneAccessView( @PathVariable UUID anonymousId, RedirectAttributes redirectAttributes ) { @@ -255,7 +258,7 @@ public Object requestGeneAccessView( @PathVariable UUID anonymousId, return modelAndView; } - @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @PreAuthorize("hasPermission(null, 'search') and hasAnyRole('USER', 'ADMIN')") @PostMapping("/search/gene/by-anonymous-id/{anonymousId}/request-access") public ModelAndView requestGeneAccess( @PathVariable UUID anonymousId, @Valid RequestAccessForm requestAccessForm, diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java index 70aba4bb..6acc4baa 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java @@ -270,6 +270,7 @@ public ModelAndView searchItlUsersByGeneView( @RequestParam String symbol, return modelAndView; } + @PreAuthorize("hasPermission(null, #remoteHost == null ? 'search' : 'international-search')") @GetMapping(value = "/search/view/user-preview/{userId}") public ModelAndView previewUser( @PathVariable Integer userId, @RequestParam(required = false) String remoteHost ) { @@ -302,6 +303,7 @@ public ModelAndView previewUser( @PathVariable Integer userId, return modelAndView; } + @PreAuthorize("hasPermission(null, #remoteHost == null ? 'search' : 'international-search')") @GetMapping(value = "/search/view/user-preview/by-anonymous-id/{anonymousId}") public ModelAndView previewAnonymousUser( @PathVariable UUID anonymousId, @RequestParam(required = false) String remoteHost ) { diff --git a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java index fbe312c3..08c8d5fb 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/SearchControllerTest.java @@ -118,11 +118,12 @@ public void setUp() { when( profileSettings.getEnabledResearcherPositions() ).thenReturn( Lists.newArrayList( "PRINCIPAL_INVESTIGATOR" ) ); when( applicationSettings.getOrgans() ).thenReturn( organSettings ); when( applicationSettings.getIsearch() ).thenReturn( iSearchSettings ); + when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); + when( permissionEvaluator.hasPermission( any(), isNull(), eq( "international-search" ) ) ).thenReturn( true ); } @Test public void getSearch_return200() throws Exception { - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); mvc.perform( get( "/search" ) ) .andExpect( status().isOk() ) .andExpect( view().name( "search" ) ); @@ -138,7 +139,6 @@ public void getSearch_withoutPublicSearch_redirect3xx() throws Exception { @Test public void getSearch_ByNameLike_return200() throws Exception { - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); mvc.perform( get( "/search" ) .param( "nameLike", "K" ) .param( "prefix", "true" ) @@ -150,7 +150,6 @@ public void getSearch_ByNameLike_return200() throws Exception { @Test public void getSearch_ByDescriptionLike_return200() throws Exception { - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); mvc.perform( get( "/search" ) .param( "descriptionLike", "pancake" ) .param( "iSearch", "false" ) ) @@ -161,7 +160,6 @@ public void getSearch_ByDescriptionLike_return200() throws Exception { @Test public void getSearch_ByGeneSymbol_return200() throws Exception { - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); Taxon humanTaxon = createTaxon( 9606 ); GeneInfo gene = createGene( 1, humanTaxon ); when( taxonService.findById( 9606 ) ).thenReturn( humanTaxon ); @@ -254,7 +252,6 @@ public void viewUser_whenRemoteIsUnavailable_thenReturnNotFound() throws Excepti @Test public void searchUsersByNameView_thenReturnSuccess() throws Exception { User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( true ); when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); mvc.perform( get( "/search/view" ) @@ -268,8 +265,8 @@ public void searchUsersByNameView_thenReturnSuccess() throws Exception { public void searchUsersByNameView_whenSearchIsUnavailable_thenReturnUnauthorized() throws Exception { // The frontend cannot handle 3xx redirection to the login page as that would return a full-fledged HTML // document, so instead it must produce a 401 Not Authorized exception - User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); when( permissionEvaluator.hasPermission( any(), isNull(), eq( "search" ) ) ).thenReturn( false ); + User user = createRemoteUser( 1, URI.create( "https://example.com/" ) ); when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); mvc.perform( get( "/search/view" ) @@ -282,7 +279,6 @@ public void searchUsersByNameView_whenSearchIsUnavailable_thenReturnUnauthorized @Test public void searchItlUsersByNameView_thenReturnSuccess() throws Exception { User user = createRemoteUser( 1, URI.create( "http://example.com/" ) ); - when( permissionEvaluator.hasPermission( any(), isNull(), eq( "international-search" ) ) ).thenReturn( true ); when( remoteResourceService.findUsersByLikeName( "Mark", true, null, null, null ) ) .thenReturn( Collections.singleton( remotify( user, User.class ) ) ); mvc.perform( get( "/search/view/international" ) From 09e500e3d8b630259a3ebccbe31120c1a07d013f Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 11:18:17 -0800 Subject: [PATCH 12/36] Add problematic URL when partner fails to produce a successful response --- .../rdp/services/RemoteResourceServiceImpl.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 92b05842..3a6c2e15 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.extern.apachecommons.CommonsLog; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.ResponseEntity; @@ -191,14 +192,15 @@ private Collection getRemoteEntities( Class arrCls, String path, Mul .path( path ) .replaceQueryParams( apiParams ) .build().toUri(); - } ).map( uri -> asyncRestTemplate.getForEntity( uri, arrCls ) ) + } ) + .map( uri -> Pair.of( uri, asyncRestTemplate.getForEntity( uri, arrCls ) ) ) // it's important to collect, otherwise the future will be created and joined on-by-one, defeating the purpose of using them - .collect( Collectors.toList() ).stream().map( future -> { + .collect( Collectors.toList() ).stream() + .map( uriAndFuture -> { try { - return future.get( applicationSettings.getIsearch().getRequestTimeout(), TimeUnit.SECONDS ); + return uriAndFuture.getRight().get( applicationSettings.getIsearch().getRequestTimeout(), TimeUnit.SECONDS ); } catch ( InterruptedException | ExecutionException | TimeoutException e ) { - // TODO: indicate the origin of the unsuccessful response - log.error( "Unsuccessful response received.", e ); + log.error( MessageFormat.format( "Unsuccessful response received for {0}.", uriAndFuture.getLeft() ), e ); return null; } } ) From a28ebef002fc08a8b581bec2744e0c7a37d6715c Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 11:22:11 -0800 Subject: [PATCH 13/36] Subject all public methods of RemoteResourceService to the international-search permission Include missing getApiVersion() and findUsersByLikeName() from the permission's purview. --- .../ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 3a6c2e15..49301d48 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -38,6 +38,7 @@ @Service("RemoteResourceService") @CommonsLog +@PreAuthorize("hasPermission(null, 'international-search')") public class RemoteResourceServiceImpl implements RemoteResourceService { private static final String API_URI = "/api"; @@ -97,7 +98,6 @@ public Collection findUsersByLikeName( String nameLike, Boolean prefix, Se } @Override - @PreAuthorize("hasPermission(null, 'international-search')") public Collection findUsersByDescription( String descriptionLike, Set researcherPositions, Collection researcherCategories, Collection organUberonIds ) { MultiValueMap params = new LinkedMultiValueMap<>(); params.add( "descriptionLike", descriptionLike ); @@ -110,7 +110,6 @@ public Collection findUsersByDescription( String descriptionLike, Set findGenesBySymbol( String symbol, Taxon taxon, Set tiers, Integer orthologTaxonId, Set researcherPositions, Set researcherCategories, Set organUberonIds ) { List intlUsergenes = new LinkedList<>(); for ( TierType tier : restrictTiers( tiers ) ) { @@ -132,7 +131,6 @@ public Collection findGenesBySymbol( String symbol, Taxon taxon, Set Date: Thu, 10 Feb 2022 11:29:34 -0800 Subject: [PATCH 14/36] api: Add AccessDeniedException to the list of exception producing a 401 Unauthorized Similarly to the SearchViewController fix we introduced earlier, this produces a suitable error response for the API. --- .../ubc/pavlab/rdp/controllers/ApiController.java | 9 ++++++--- .../ubc/pavlab/rdp/controllers/ApiControllerTest.java | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 66ba2832..0407ff64 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -8,6 +8,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -60,9 +61,11 @@ public class ApiController { @Autowired private PermissionEvaluator permissionEvaluator; - @ExceptionHandler({ AuthenticationException.class }) - public ResponseEntity handleAuthenticationException() { - return ResponseEntity.status( HttpStatus.UNAUTHORIZED ).build(); + @ExceptionHandler({ AuthenticationException.class, AccessDeniedException.class }) + public ResponseEntity handleAuthenticationExceptionAndAccessDeniedException( Exception e ) { + return ResponseEntity.status( HttpStatus.UNAUTHORIZED ) + .contentType( MediaType.TEXT_PLAIN ) + .body( e.getMessage() ); } @ExceptionHandler({ ApiException.class }) diff --git a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java index cf9c8be3..c135baad 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java @@ -10,6 +10,7 @@ import org.springframework.context.MessageSource; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.PermissionEvaluator; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.test.context.junit4.SpringRunner; @@ -91,6 +92,16 @@ public void accessUnmappedRoute_thenReturn404() throws Exception { .andExpect( content().contentType( MediaType.TEXT_PLAIN ) ); } + @Test + public void accessWithoutProperRole_thenReturn401() throws Exception { + when( userService.countResearchers() ) + .thenThrow( AccessDeniedException.class ); + mvc.perform( get( "/api/stats" ) ) + .andExpect( status().isUnauthorized() ) + .andExpect( content().contentType( MediaType.TEXT_PLAIN ) ); + verify( userService ).countResearchers(); + } + @Test public void searchGenes_withSearchDisabled_thenReturnServiceUnavailable() throws Exception { when( iSearchSettings.isEnabled() ).thenReturn( false ); From 5f3eb7067d681fd1942b696a3305e07cbc2d032a Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 11:35:22 -0800 Subject: [PATCH 15/36] api: Simplify check for no orthologs found --- .../java/ubc/pavlab/rdp/controllers/ApiController.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 0407ff64..9e2da921 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -233,11 +233,8 @@ public Object searchUsersByGeneSymbol( @RequestParam String symbol, .filter( g -> orthologTaxon == null || g.getTaxon().equals( orthologTaxon ) ) .collect( Collectors.toSet() ); - if ( - // Check if there is a ortholog request for a different taxon than the original gene - ( orthologTaxonId != null && !orthologTaxonId.equals( gene.getTaxon().getId() ) ) - // Check if we got some ortholog results - && ( orthologs == null || orthologs.isEmpty() ) ) { + // Check if there is an ortholog request for a different taxon than the original gene + if ( orthologTaxon != null && !orthologTaxon.equals( gene.getTaxon() ) && orthologs.isEmpty() ) { return new ResponseEntity<>( messageSource.getMessage( "ApiController.noOrthologsWithGivenParameters", null, locale ), null, HttpStatus.NOT_FOUND ); } From 6a0b5ffb7cdefa759b4086a2f78d8de9691afeb4 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 12:07:03 -0800 Subject: [PATCH 16/36] Handle unmapped routes in /search/view with an error fragment --- .../rdp/controllers/SearchViewController.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java index 6acc4baa..0c1115e8 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java @@ -7,10 +7,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.ModelAndView; import ubc.pavlab.rdp.exception.RemoteException; import ubc.pavlab.rdp.model.*; @@ -58,6 +55,19 @@ public ModelAndView handleAccessDeniedForViewTemplates( AccessDeniedException ex return modelAndView; } + /** + * Usually, we would have this handled by a 404 error page, but in the case of this endpoint, it will break the + * client expecting a partial HTML fragment. + */ + @RequestMapping("/search/view/*") + public ModelAndView handleMissingRoute() { + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setStatus( HttpStatus.NOT_FOUND ); + modelAndView.setViewName( "fragments/error::message" ); + modelAndView.addObject( "errorMessage", "No endpoint found for your request URL." ); + return modelAndView; + } + @PreAuthorize("hasPermission(null, 'international-search')") @GetMapping(value = "/search/view/international", params = { "nameLike" }) public ModelAndView searchItlUsersByNameView( @RequestParam String nameLike, From 58b69421721c477ea5395569b0bc3dbb66505dd4 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 13:38:51 -0800 Subject: [PATCH 17/36] Use Apache Maven Wrapper --- .mvn/wrapper/maven-wrapper.jar | Bin 50710 -> 58727 bytes .mvn/wrapper/maven-wrapper.properties | 18 +- mvnw | 18 +- mvnw.cmd | 370 +++++++++++++------------- 4 files changed, 217 insertions(+), 189 deletions(-) diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar index 2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054..c1dd12f17644411d6e840bd5a10c6ecda0175f18 100644 GIT binary patch literal 58727 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%0ANsA|A3-jj|r`KAo%oTajX3>^E` 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 YM{}PByk10KD_nuQ4E7Du?}+~TKh4V)`~Uy| literal 50710 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 EfAhh=DF6Tf diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 642d572c..e83fa695 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/mvnw b/mvnw index 41c0f0c2..5643201c 100755 --- a/mvnw +++ b/mvnw @@ -36,6 +36,10 @@ if [ -z "$MAVEN_SKIP_RC" ] ; then + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + if [ -f /etc/mavenrc ] ; then . /etc/mavenrc fi @@ -145,7 +149,7 @@ if [ -z "$JAVACMD" ] ; then JAVACMD="$JAVA_HOME/bin/java" fi else - JAVACMD="`which java`" + JAVACMD="`\\unset -f command; \\command -v java`" fi fi @@ -212,9 +216,9 @@ else echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." fi if [ -n "$MVNW_REPOURL" ]; then - jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" else - jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" fi while IFS="=" read key value; do case "$key" in (wrapperUrl) jarUrl="$value"; break ;; @@ -233,9 +237,9 @@ else echo "Found wget ... using wget" fi if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget "$jarUrl" -O "$wrapperJarPath" + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" else - wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" fi elif command -v curl > /dev/null; then if [ "$MVNW_VERBOSE" = true ]; then @@ -305,6 +309,8 @@ WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain exec "$JAVACMD" \ $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 86115719..23b7079a 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,182 +1,188 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Maven Start Up Batch script -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" -if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" - -FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %DOWNLOAD_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" -if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%" == "on" pause - -if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% - -exit /B %ERROR_CODE% +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% From 7b90e9e4dc5829dfe8c0016dd121bea15de8de14 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Thu, 10 Feb 2022 13:48:40 -0800 Subject: [PATCH 18/36] Fix researcher category test failure --- .../java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java b/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java index 46db7fa8..a5dfaf05 100644 --- a/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java +++ b/src/test/java/ubc/pavlab/rdp/repositories/UserRepositoryTest.java @@ -479,7 +479,7 @@ public void save_whenUserHasMultipleResearcherCategories_thenSucceed() { user.getProfile().getResearcherCategories().add( ResearcherCategory.IN_VIVO ); user = entityManager.persistAndFlush( user ); assertThat( user.getProfile().getResearcherCategories() ) - .containsExactly( ResearcherCategory.IN_SILICO, ResearcherCategory.IN_VIVO ); + .containsExactlyInAnyOrder( ResearcherCategory.IN_SILICO, ResearcherCategory.IN_VIVO ); } @Test From 4b3973d2f4410bd486d66c32695e5c5ff74145cd Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 11:01:45 -0800 Subject: [PATCH 19/36] api: Remove redundant routes for user and gene search --- .../pavlab/rdp/controllers/ApiController.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 9e2da921..62706921 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -144,26 +144,6 @@ public Object getGenes( @RequestHeader(value = HttpHeaders.AUTHORIZATION, requir } } - /** - * Hides the default users search 400 page when no parameters are provided. - * - * @return 404. - */ - @GetMapping(value = "/api/users/search", produces = MediaType.APPLICATION_JSON_VALUE) - public Object searchUsers() { - return ResponseEntity.notFound().build(); - } - - /** - * Hides the default genes search 400 page when no parameters are provided. - * - * @return 404. - */ - @GetMapping(value = "/api/genes/search", produces = MediaType.APPLICATION_JSON_VALUE) - public Object searchGenes() { - return ResponseEntity.notFound().build(); - } - @GetMapping(value = "/api/users/search", params = { "nameLike" }, produces = MediaType.APPLICATION_JSON_VALUE) public Object searchUsersByName( @RequestParam String nameLike, @RequestParam Boolean prefix, From 55c1c958aee05f15aee516ace0a913e3fa67abc9 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 11:04:22 -0800 Subject: [PATCH 20/36] Improve check for remote search user Rename UserService.getRemoteAdmin() to UserService.getRemoteSearchUser() since that user is not necessarily an administrator. Use an Optional in UserService.getRemoteUser() so that an unconfigured remote search user can be handled nicely. Cache the remote user since it's hard-coded in the configuration. --- .../ubc/pavlab/rdp/controllers/ApiController.java | 6 ++---- .../pavlab/rdp/services/PrivacyServiceImpl.java | 15 ++++++++++----- .../java/ubc/pavlab/rdp/services/UserService.java | 3 +-- .../ubc/pavlab/rdp/services/UserServiceImpl.java | 9 +++++++-- src/main/resources/ehcache.xml | 4 ++++ .../pavlab/rdp/controllers/ApiControllerTest.java | 10 ++++++---- .../rdp/security/PrivacyServiceImplTest.java | 1 + 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 62706921..32ea9af0 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -317,10 +317,8 @@ private void checkAuth( String authorizationHeader, String authToken ) throws Au return; } else if ( applicationSettings.getIsearch().getAuthTokens().contains( authToken ) ) { // remote admin authentication - u = userService.getRemoteAdmin(); - if ( u == null ) { - throw new ApiException( HttpStatus.SERVICE_UNAVAILABLE, messageSource.getMessage( "ApiController.misconfiguredRemoteAdmin", null, Locale.getDefault() ) ); - } + u = userService.getRemoteSearchUser() + .orElseThrow( () -> new ApiException( HttpStatus.SERVICE_UNAVAILABLE, messageSource.getMessage( "ApiController.misconfiguredRemoteAdmin", null, Locale.getDefault() ) ) ); } else { // authentication via access token try { diff --git a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java index c080a278..5913fe71 100644 --- a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java @@ -78,7 +78,7 @@ public boolean checkUserCanUpdate( User user, UserContent userContent ) { private boolean checkUserCanSeeOtherUserContentWithPrivacyLevel( User currentUser, User otherUser, PrivacyLevelType privacyLevel ) { // Never show the remote admin profile (or accidental null users) - if ( otherUser == null || ( applicationSettings.getIsearch() != null && otherUser.getId().equals( applicationSettings.getIsearch().getUserId() ) ) ) { + if ( otherUser == null || ( applicationSettings.getIsearch() != null && isRemoteSearchUser( otherUser ) ) ) { return false; } @@ -94,14 +94,15 @@ private boolean checkUserCanSeeOtherUserContentWithPrivacyLevel( User currentUse return false; } + // Either the user is looking at himself, or the user is public, or shared with registered users - check for any logged-in user, or private - check for admin; If logged-in user is admin, we have to // check whether this user is the designated actor for the authenticated remote search, in which case we have to check for remote search privileges on the user. return otherUser.equals( currentUser ) // User is looking at himself || ( privacyLevel.equals( PrivacyLevelType.PUBLIC ) ) // Data is public - || ( privacyLevel.equals( PrivacyLevelType.SHARED ) && currentUser != null && !currentUser.getId().equals( applicationSettings.getIsearch().getUserId() ) )// data is accessible for registerd users and there is a user logged in who is not the remote admin - || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && !currentUser.getId().equals( applicationSettings.getIsearch().getUserId() ) )// data is private and there is an admin logged in who is not the remote admin - || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getServiceAccountRole() ) && !currentUser.getId().equals( applicationSettings.getIsearch().getUserId() ) ) // user is a service account - || ( profile.isShared() && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && currentUser.getId().equals( applicationSettings.getIsearch().getUserId() ) ); // data is designated as remotely shared and there is an admin logged in who is the remote admin + || ( privacyLevel.equals( PrivacyLevelType.SHARED ) && currentUser != null && !isRemoteSearchUser( currentUser ) )// data is accessible for registerd users and there is a user logged in who is not the remote admin + || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && !isRemoteSearchUser( currentUser ) )// data is private and there is an admin logged in who is not the remote search user + || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getServiceAccountRole() ) && !isRemoteSearchUser( currentUser ) ) // user is a service account + || ( profile.isShared() && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && isRemoteSearchUser( currentUser ) ); // data is designated as remotely shared and there is an admin logged in who is the remote search user } @Cacheable(value = "ubc.pavlab.rdp.model.Role.byRole", key = "'ROLE_ADMIN'") @@ -113,4 +114,8 @@ public Role getAdminRole() { public Role getServiceAccountRole() { return roleRepository.findByRole( "ROLE_SERVICE_ACCOUNT" ); } + + private boolean isRemoteSearchUser( User user ) { + return userService.getRemoteSearchUser().filter( user::equals ).isPresent(); + } } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserService.java b/src/main/java/ubc/pavlab/rdp/services/UserService.java index 92db57c9..df617ecd 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserService.java @@ -3,7 +3,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.transaction.annotation.Transactional; import ubc.pavlab.rdp.exception.TokenException; import ubc.pavlab.rdp.model.*; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; @@ -57,7 +56,7 @@ public interface UserService { AccessToken createAccessTokenForUser( User user ); - User getRemoteAdmin(); + Optional getRemoteSearchUser(); Collection findAll(); diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 54139c75..51209837 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -310,8 +310,13 @@ public AccessToken createAccessTokenForUser( User user ) { } @Override - public User getRemoteAdmin() { - return userRepository.findOneWithRoles( applicationSettings.getIsearch().getUserId() ); + @Cacheable("ubc.pavlab.rdp.services.UserService.remoteSearchUser") + public Optional getRemoteSearchUser() { + if ( applicationSettings.getIsearch().getUserId() == null ) { + // there is no configured remote search user + return Optional.empty(); + } + return Optional.ofNullable( userRepository.findOneWithRoles( applicationSettings.getIsearch().getUserId() ) ); } @Override diff --git a/src/main/resources/ehcache.xml b/src/main/resources/ehcache.xml index cc17c8c1..3ff16c5d 100644 --- a/src/main/resources/ehcache.xml +++ b/src/main/resources/ehcache.xml @@ -58,4 +58,8 @@ + + \ No newline at end of file diff --git a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java index c135baad..9effc221 100644 --- a/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java +++ b/src/test/java/ubc/pavlab/rdp/controllers/ApiControllerTest.java @@ -29,6 +29,7 @@ import java.net.URI; import java.util.Collections; import java.util.EnumSet; +import java.util.Optional; import java.util.UUID; import static org.mockito.Matchers.any; @@ -83,6 +84,7 @@ public void setUp() { when( iSearchSettings.isEnabled() ).thenReturn( true ); when( siteSettings.getHostUri() ).thenReturn( URI.create( "http://localhost/" ) ); when( messageSource.getMessage( eq( "rdp.site.shortname" ), any(), any() ) ).thenReturn( "RDMM" ); + when( userService.getRemoteSearchUser() ).thenReturn( Optional.empty() ); } @Test @@ -117,7 +119,7 @@ public void searchGenes_withSearchDisabled_thenReturnServiceUnavailable() throws public void searchGenes_withAuthToken_thenReturnSuccess() throws Exception { // configure remote authentication when( iSearchSettings.getAuthTokens() ).thenReturn( Collections.singletonList( "1234" ) ); - when( userService.getRemoteAdmin() ).thenReturn( createUser( 1 ) ); + when( userService.getRemoteSearchUser() ).thenReturn( Optional.of( createUser( 1 ) ) ); // configure one search result Taxon humanTaxon = createTaxon( 9606 ); @@ -136,14 +138,14 @@ public void searchGenes_withAuthToken_thenReturnSuccess() throws Exception { .param( "tier", "TIER1" ) ) .andExpect( status().is2xxSuccessful() ); - verify( userService ).getRemoteAdmin(); + verify( userService ).getRemoteSearchUser(); } @Test public void searchGenes_withAuthTokenInQuery_thenReturnSuccess() throws Exception { // configure remote authentication when( iSearchSettings.getAuthTokens() ).thenReturn( Collections.singletonList( "1234" ) ); - when( userService.getRemoteAdmin() ).thenReturn( createUser( 1 ) ); + when( userService.getRemoteSearchUser() ).thenReturn( Optional.of( createUser( 1 ) ) ); // configure one search result Taxon humanTaxon = createTaxon( 9606 ); @@ -162,7 +164,7 @@ public void searchGenes_withAuthTokenInQuery_thenReturnSuccess() throws Exceptio .param( "auth", "1234" ) ) .andExpect( status().is2xxSuccessful() ); - verify( userService ).getRemoteAdmin(); + verify( userService ).getRemoteSearchUser(); } @Test diff --git a/src/test/java/ubc/pavlab/rdp/security/PrivacyServiceImplTest.java b/src/test/java/ubc/pavlab/rdp/security/PrivacyServiceImplTest.java index cdd6c544..ce0a9531 100644 --- a/src/test/java/ubc/pavlab/rdp/security/PrivacyServiceImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/security/PrivacyServiceImplTest.java @@ -78,6 +78,7 @@ public void setUp() { when( applicationSettings.getPrivacy() ).thenReturn( privacySettings ); when( applicationSettings.getIsearch() ).thenReturn( iSearchSettings ); when( privacySettings.isRegisteredSearch() ).thenReturn( true ); + when( userService.getRemoteSearchUser() ).thenReturn( Optional.empty() ); } @Test From bc15d466569f4b675224689d702b771cff15a23a Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 11:12:27 -0800 Subject: [PATCH 21/36] Minor cleanups for PermissionEvaluatorImpl and its tests --- .../ubc/pavlab/rdp/security/PermissionEvaluatorImpl.java | 8 ++------ .../pavlab/rdp/security/PermissionEvaluatorImplTest.java | 9 +++------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/security/PermissionEvaluatorImpl.java b/src/main/java/ubc/pavlab/rdp/security/PermissionEvaluatorImpl.java index aac86597..5227583d 100644 --- a/src/main/java/ubc/pavlab/rdp/security/PermissionEvaluatorImpl.java +++ b/src/main/java/ubc/pavlab/rdp/security/PermissionEvaluatorImpl.java @@ -8,7 +8,6 @@ import ubc.pavlab.rdp.model.User; import ubc.pavlab.rdp.model.UserContent; import ubc.pavlab.rdp.model.UserPrinciple; -import ubc.pavlab.rdp.repositories.RoleRepository; import ubc.pavlab.rdp.services.PrivacyService; import ubc.pavlab.rdp.services.UserService; @@ -21,9 +20,6 @@ public class PermissionEvaluatorImpl implements PermissionEvaluator { @Autowired private UserService userService; - @Autowired - private RoleRepository roleRepository; - @Autowired private PrivacyService privacyService; @@ -42,11 +38,11 @@ public boolean hasPermission( Authentication authentication, Object targetDomain } } - throw new UnsupportedOperationException( "" ); + throw new UnsupportedOperationException( "Permission " + permission + " is not supported." ); } @Override public boolean hasPermission( Authentication authentication, Serializable targetId, String targetType, Object permission ) { - throw new UnsupportedOperationException( "" ); + throw new UnsupportedOperationException( "Permission on target ID is not supported." ); } } diff --git a/src/test/java/ubc/pavlab/rdp/security/PermissionEvaluatorImplTest.java b/src/test/java/ubc/pavlab/rdp/security/PermissionEvaluatorImplTest.java index 056369ca..310f4d44 100644 --- a/src/test/java/ubc/pavlab/rdp/security/PermissionEvaluatorImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/security/PermissionEvaluatorImplTest.java @@ -38,15 +38,12 @@ public PermissionEvaluator permissionEvaluator() { @MockBean UserService userService; - @MockBean - RoleRepository roleRepository; - @MockBean PrivacyService privacyService; - Authentication auth; - - UserContent userContent; + /* fixtures */ + private Authentication auth; + private UserContent userContent; @Before public void setUp() { From 4bafade1dae8b6b4e7605de89de65136935cbece Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 11:18:45 -0800 Subject: [PATCH 22/36] Log unauthorized access to /search/view and /api --- src/main/java/ubc/pavlab/rdp/controllers/ApiController.java | 4 +++- .../java/ubc/pavlab/rdp/controllers/SearchViewController.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 32ea9af0..1205b899 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -27,6 +27,7 @@ import ubc.pavlab.rdp.settings.ApplicationSettings; import ubc.pavlab.rdp.settings.SiteSettings; +import javax.servlet.http.HttpServletRequest; import java.text.MessageFormat; import java.util.*; import java.util.stream.Collectors; @@ -62,7 +63,8 @@ public class ApiController { private PermissionEvaluator permissionEvaluator; @ExceptionHandler({ AuthenticationException.class, AccessDeniedException.class }) - public ResponseEntity handleAuthenticationExceptionAndAccessDeniedException( Exception e ) { + public ResponseEntity handleAuthenticationExceptionAndAccessDeniedException( HttpServletRequest req, Exception e ) { + log.warn( "Unauthorized access to the API via " + req.getRequestURI() + ".", e ); return ResponseEntity.status( HttpStatus.UNAUTHORIZED ) .contentType( MediaType.TEXT_PLAIN ) .body( e.getMessage() ); diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java index 0c1115e8..9b78f07f 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java @@ -16,6 +16,7 @@ import ubc.pavlab.rdp.model.enums.TierType; import ubc.pavlab.rdp.services.*; +import javax.servlet.http.HttpServletRequest; import java.net.URI; import java.text.MessageFormat; import java.util.*; @@ -47,7 +48,8 @@ public class SearchViewController { private GeneInfoService geneInfoService; @ExceptionHandler({ AccessDeniedException.class }) - public ModelAndView handleAccessDeniedForViewTemplates( AccessDeniedException exception ) { + public ModelAndView handleAccessDeniedForViewTemplates( HttpServletRequest req, AccessDeniedException exception ) { + log.warn( "Unauthorized access to the search view via " + req.getRequestURI() + ".", exception ); ModelAndView modelAndView = new ModelAndView(); modelAndView.setStatus( HttpStatus.UNAUTHORIZED ); modelAndView.setViewName( "fragments/error::message" ); From d923a1df27f2edee6367bd445a3fb45c590639b6 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 11:47:27 -0800 Subject: [PATCH 23/36] Handle potential NullPointerException when sorting search results by taxon ordering --- src/main/java/ubc/pavlab/rdp/model/Taxon.java | 7 +------ .../java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/model/Taxon.java b/src/main/java/ubc/pavlab/rdp/model/Taxon.java index 7cd42acc..63d87910 100644 --- a/src/main/java/ubc/pavlab/rdp/model/Taxon.java +++ b/src/main/java/ubc/pavlab/rdp/model/Taxon.java @@ -22,7 +22,7 @@ @NoArgsConstructor @EqualsAndHashCode(of = { "id" }) @ToString(of = { "id", "scientificName" }) -public class Taxon implements Comparable, Serializable { +public class Taxon implements Serializable { @Id @Column(name = "taxon_id") @@ -41,9 +41,4 @@ public class Taxon implements Comparable, Serializable { @JsonIgnore private Integer ordering; - - @Override - public int compareTo( Taxon taxon ) { - return ordering.compareTo( taxon.ordering ); - } } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 22ea258b..7272abf4 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -141,7 +141,7 @@ public List handleGeneSearch( Gene gene, Set tiers, Taxon or } return results.stream() .sorted( comparing( UserGene::getAnonymousId, nullsFirst( naturalOrder() ) ) - .thenComparing( UserGene::getTaxon ) + .thenComparing( ug -> ug.getTaxon().getOrdering(), Comparator.nullsLast( naturalOrder() ) ) .thenComparing( UserGene::getTier ) .thenComparing( ug -> ug.getUser().getProfile().getFullName() ) ) .collect( Collectors.toList() ); // we need to preserve the search order From 1fe909290847c57bbdb05ebee491c287a42c3af8 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 12:21:11 -0800 Subject: [PATCH 24/36] Use OrganInfo instead of UserOrgan for searching by organ systems --- .../pavlab/rdp/controllers/ApiController.java | 6 ++--- .../rdp/controllers/SearchController.java | 6 ++--- .../rdp/controllers/SearchViewController.java | 6 ++--- .../rdp/repositories/UserGeneRepository.java | 2 -- .../rdp/repositories/UserOrganRepository.java | 2 -- .../pavlab/rdp/services/UserGeneService.java | 18 ++++++++++----- .../rdp/services/UserGeneServiceImpl.java | 20 +++++++++++----- .../pavlab/rdp/services/UserOrganService.java | 9 -------- .../rdp/services/UserOrganServiceImpl.java | 16 ------------- .../ubc/pavlab/rdp/services/UserService.java | 6 ++--- .../pavlab/rdp/services/UserServiceImpl.java | 23 ++++++++++++++----- .../repositories/UserOrganRepositoryTest.java | 3 --- 12 files changed, 55 insertions(+), 62 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index 1205b899..ebdb03cb 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -52,7 +52,7 @@ public class ApiController { @Autowired private GeneInfoService geneService; @Autowired - private UserOrganService userOrganService; + private OrganInfoService organInfoService; @Autowired private UserGeneService userGeneService; @Autowired @@ -380,8 +380,8 @@ private Set restrictTiers( Set tiers ) { .collect( Collectors.toSet() ); } - private Collection organsFromUberonIds( Set organUberonIds ) { - return organUberonIds == null ? null : userOrganService.findByUberonIdIn( organUberonIds ); + private Collection organsFromUberonIds( Set organUberonIds ) { + return organUberonIds == null ? null : organInfoService.findByUberonIdIn( organUberonIds ); } } diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java index b02e31d2..399bf765 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchController.java @@ -54,7 +54,7 @@ public class SearchController { private UserGeneService userGeneService; @Autowired - private UserOrganService userOrganService; + private OrganInfoService organInfoService; @Autowired private RemoteResourceService remoteResourceService; @@ -284,7 +284,7 @@ public ModelAndView requestGeneAccess( @PathVariable UUID anonymousId, return modelAndView; } - private Collection organsFromUberonIds( Set organUberonIds ) { - return organUberonIds == null ? null : userOrganService.findByUberonIdIn( organUberonIds ); + private Collection organsFromUberonIds( Set organUberonIds ) { + return organUberonIds == null ? null : organInfoService.findByUberonIdIn( organUberonIds ); } } diff --git a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java index 9b78f07f..f37381eb 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/SearchViewController.java @@ -39,7 +39,7 @@ public class SearchViewController { private UserGeneService userGeneService; @Autowired - private UserOrganService userOrganService; + private OrganInfoService organInfoService; @Autowired private RemoteResourceService remoteResourceService; @@ -354,7 +354,7 @@ private boolean userPreviewIsEmpty( User user ) { user.getUserOrgans().isEmpty(); } - private Collection organsFromUberonIds( Set organUberonIds ) { - return organUberonIds == null ? null : userOrganService.findByUberonIdIn( organUberonIds ); + private Collection organsFromUberonIds( Set organUberonIds ) { + return organUberonIds == null ? null : organInfoService.findByUberonIdIn( organUberonIds ); } } diff --git a/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java index d3819502..5772cfe4 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java @@ -67,8 +67,6 @@ public interface UserGeneRepository extends JpaRepository { UserGene findBySymbolAndTaxon( String symbol, Taxon taxon ); - Collection findByGeneIdAndTierAndUserUserOrgansIn( int geneId, TierType tier, Collection organs ); - Collection findByGeneIdAndTierInAndUserUserOrgansIn( int geneId, Set tiers, Collection organs ); /** diff --git a/src/main/java/ubc/pavlab/rdp/repositories/UserOrganRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/UserOrganRepository.java index 7b627403..80e5a13e 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/UserOrganRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/UserOrganRepository.java @@ -10,6 +10,4 @@ public interface UserOrganRepository extends JpaRepository { Collection findByDescriptionContainingIgnoreCase( String description ); - - Collection findByUberonIdIn( Collection organUberonIds ); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneService.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneService.java index 47ded25d..fda52b88 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneService.java @@ -21,10 +21,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import ubc.pavlab.rdp.model.Gene; -import ubc.pavlab.rdp.model.Taxon; -import ubc.pavlab.rdp.model.UserGene; -import ubc.pavlab.rdp.model.UserOrgan; +import ubc.pavlab.rdp.model.*; import ubc.pavlab.rdp.model.enums.PrivacyLevelType; import ubc.pavlab.rdp.model.enums.ResearcherCategory; import ubc.pavlab.rdp.model.enums.ResearcherPosition; @@ -57,11 +54,20 @@ public interface UserGeneService { Integer countUniqueAssociationsToHumanAllTiers(); /** + * Perform a search and retrieve all the user genes that match the provided gene description and optional + * constraints. + *

* Results are sorted by taxon (according to {@link Taxon#getOrdering()}), tier type, researcher last name and * first name. Anonymous results are always displayed last. - * @return + * @param tiers only retain results in the given {@link TierType}, or any if null + * @param orthologTaxon only retain results in the given ortholog {@link Taxon}, or any if null + * @param researcherPositions only retain results where the corresponding {@link User} holds any given {@link ResearcherPosition}, + * or any if null + * @param researcherTypes only retain results where the corresponding {@link User} has any of the given {@link ResearcherCategory} +* or any if null + * @param organs only retain results where the corresponding {@link User} tracks any of the given {@link UserOrgan} */ - List handleGeneSearch( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherTypes, Collection organs ); + List handleGeneSearch( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherTypes, Collection organs ); void updateUserGenes(); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 7272abf4..90c75ac3 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -128,7 +128,7 @@ public Integer countUniqueAssociationsToHumanAllTiers() { @Override @PostFilter("hasPermission(filterObject, 'read')") - public List handleGeneSearch( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection organs ) { + public List handleGeneSearch( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection organs ) { Set results; if ( applicationSettings.getPrivacy().isEnableAnonymizedSearchResults() ) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); @@ -147,30 +147,38 @@ public List handleGeneSearch( Gene gene, Set tiers, Taxon or .collect( Collectors.toList() ); // we need to preserve the search order } - private Set handleGeneSearchInternal( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection organs ) { + private Set handleGeneSearchInternal( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection organs ) { Set uGenes = new LinkedHashSet<>(); + // do this once to save time in the inner loop + final Set organUberonIds; + if ( organs != null ) { + organUberonIds = organs.stream().map( Organ::getUberonId ).collect( Collectors.toSet() ); + } else { + organUberonIds = null; + } + // ortholog relationship is not reflexive (i.e. a gene is not its own ortholog), but we still want to display // that gene first when ortholog search is performed in the same MO if ( orthologTaxon == null || gene.getTaxon().equals( orthologTaxon ) ) { uGenes.addAll( userGeneRepository.findByGeneIdAndTierIn( gene.getGeneId(), tiers ).stream() .filter( ug -> researcherPositions == null || researcherPositions.contains( ug.getUser().getProfile().getResearcherPosition() ) ) .filter( ug -> researcherCategories == null || containsAny( researcherCategories, ug.getUser().getProfile().getResearcherCategories() ) ) - .filter( ortholog -> organs == null || containsAny( organs, ortholog.getUser().getUserOrgans().values() ) ) + .filter( ortholog -> organUberonIds == null || containsAny( organUberonIds, ortholog.getUser().getUserOrgans().values().stream().map( UserOrgan::getUberonId ).collect( Collectors.toSet() ) ) ) .collect( Collectors.toSet() ) ); } - uGenes.addAll( handleOrthologSearchInternal( gene, tiers, orthologTaxon, researcherPositions, researcherCategories, organs ) ); + uGenes.addAll( handleOrthologSearchInternal( gene, tiers, orthologTaxon, researcherPositions, researcherCategories, organUberonIds ) ); return uGenes; } - private Set handleOrthologSearchInternal( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection userOrgans ) { + private Set handleOrthologSearchInternal( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherCategories, Collection organUberonIds ) { return ( orthologTaxon == null ? userGeneRepository.findOrthologsByGeneId( gene.getGeneId() ) : userGeneRepository.findOrthologsByGeneIdAndTaxon( gene.getGeneId(), orthologTaxon ) ).stream() .filter( ortholog -> tiers.contains( ortholog.getTier() ) ) .filter( ug -> researcherPositions == null || researcherPositions.contains( ug.getUser().getProfile().getResearcherPosition() ) ) .filter( ug -> researcherCategories == null || containsAny( researcherCategories, ug.getUser().getProfile().getResearcherCategories() ) ) - .filter( ortholog -> userOrgans == null || containsAny( userOrgans, ortholog.getUser().getUserOrgans().values() ) ) + .filter( ortholog -> organUberonIds == null || containsAny( organUberonIds, ortholog.getUser().getUserOrgans().values().stream().map( UserOrgan::getUberonId ).collect( Collectors.toSet() ) ) ) .collect( Collectors.toSet() ); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserOrganService.java b/src/main/java/ubc/pavlab/rdp/services/UserOrganService.java index 0054a98d..4b3e3fcb 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserOrganService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserOrganService.java @@ -1,15 +1,6 @@ package ubc.pavlab.rdp.services; -import ubc.pavlab.rdp.model.UserOrgan; - -import java.util.Collection; -import java.util.Set; - public interface UserOrganService { - Collection findByDescription( String description ); - - Collection findByUberonIdIn( Set organUberonIds ); - void updateUserOrgans(); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserOrganServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserOrganServiceImpl.java index 31e29277..a574dea7 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserOrganServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserOrganServiceImpl.java @@ -2,7 +2,6 @@ import lombok.extern.apachecommons.CommonsLog; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.access.prepost.PostFilter; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ubc.pavlab.rdp.model.OrganInfo; @@ -10,9 +9,6 @@ import ubc.pavlab.rdp.repositories.OrganInfoRepository; import ubc.pavlab.rdp.repositories.UserOrganRepository; -import java.util.Collection; -import java.util.Set; - @CommonsLog @Service("userOrganService") public class UserOrganServiceImpl implements UserOrganService { @@ -20,18 +16,6 @@ public class UserOrganServiceImpl implements UserOrganService { @Autowired private UserOrganRepository userOrganRepository; - @Override - @PostFilter("hasPermission(filterObject, 'read')") - public Collection findByDescription( String description ) { - return userOrganRepository.findByDescriptionContainingIgnoreCase( description ); - } - - @Override - @PostFilter("hasPermission(filterObject, 'read')") - public Collection findByUberonIdIn( Set organUberonIds ) { - return userOrganRepository.findByUberonIdIn( organUberonIds ); - } - @Autowired private OrganInfoRepository organInfoRepository; diff --git a/src/main/java/ubc/pavlab/rdp/services/UserService.java b/src/main/java/ubc/pavlab/rdp/services/UserService.java index df617ecd..e49c357e 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserService.java @@ -64,11 +64,11 @@ public interface UserService { Page findAllByPrivacyLevel( PrivacyLevelType privacyLevel, Pageable pageable ); - Collection findByLikeName( String nameLike, Set researcherPositions, Set researcherTypes, Collection userOrgans ); + Collection findByLikeName( String nameLike, Set researcherPositions, Set researcherTypes, Collection userOrgans ); - Collection findByStartsName( String startsName, Set researcherPositions, Set researcherTypes, Collection userOrgans ); + Collection findByStartsName( String startsName, Set researcherPositions, Set researcherTypes, Collection userOrgans ); - Collection findByDescription( String descriptionLike, Set researcherPositions, Collection researcherTypes, Collection userOrgans ); + Collection findByDescription( String descriptionLike, Set researcherPositions, Collection researcherTypes, Collection userOrgans ); long countResearchers(); diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 51209837..cdc49cbb 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -337,34 +337,45 @@ public Page findAllByPrivacyLevel( PrivacyLevelType privacyLevel, Pageable @Override @PostFilter("hasPermission(filterObject, 'read')") - public Collection findByLikeName( String nameLike, Set researcherPositions, Set researcherTypes, Collection userOrgans ) { + public Collection findByLikeName( String nameLike, Set researcherPositions, Set researcherTypes, Collection organs ) { + final Set organUberonIds = organUberonIdsFromOrgans( organs ); return userRepository.findByProfileNameContainingIgnoreCaseOrProfileLastNameContainingIgnoreCase( nameLike, nameLike ).stream() .filter( u -> researcherPositions == null || researcherPositions.contains( u.getProfile().getResearcherPosition() ) ) .filter( u -> researcherTypes == null || containsAny( researcherTypes, u.getProfile().getResearcherCategories() ) ) - .filter( u -> userOrgans == null || containsAny( userOrgans, u.getUserOrgans().values() ) ) + .filter( u -> organUberonIds == null || containsAny( organUberonIds, u.getUserOrgans().values().stream().map( UserOrgan::getUberonId ).collect( Collectors.toSet() ) ) ) .collect( Collectors.toSet() ); } @Override @PostFilter("hasPermission(filterObject, 'read')") - public Collection findByStartsName( String startsName, Set researcherPositions, Set researcherTypes, Collection userOrgans ) { + public Collection findByStartsName( String startsName, Set researcherPositions, Set researcherTypes, Collection organs ) { + final Set organUberonIds = organUberonIdsFromOrgans( organs ); return userRepository.findByProfileLastNameStartsWithIgnoreCase( startsName ).stream() .filter( u -> researcherPositions == null || researcherPositions.contains( u.getProfile().getResearcherPosition() ) ) .filter( u -> researcherTypes == null || containsAny( researcherTypes, u.getProfile().getResearcherCategories() ) ) - .filter( u -> userOrgans == null || containsAny( userOrgans, u.getUserOrgans().values() ) ) + .filter( u -> organUberonIds == null || containsAny( organUberonIds, u.getUserOrgans().values().stream().map( UserOrgan::getUberonId ).collect( Collectors.toSet() ) ) ) .collect( Collectors.toSet() ); } @Override @PostFilter("hasPermission(filterObject, 'read')") - public Collection findByDescription( String descriptionLike, Set researcherPositions, Collection researcherTypes, Collection userOrgans ) { + public Collection findByDescription( String descriptionLike, Set researcherPositions, Collection researcherTypes, Collection organs ) { + final Set organUberonIds = organUberonIdsFromOrgans( organs ); return userRepository.findByProfileDescriptionContainingIgnoreCaseOrTaxonDescriptionsContainingIgnoreCase( descriptionLike, descriptionLike ).stream() .filter( u -> researcherPositions == null || researcherPositions.contains( u.getProfile().getResearcherPosition() ) ) .filter( u -> researcherTypes == null || containsAny( researcherTypes, u.getProfile().getResearcherCategories() ) ) - .filter( u -> userOrgans == null || containsAny( userOrgans, u.getUserOrgans().values() ) ) + .filter( u -> organUberonIds == null || containsAny( organUberonIds, u.getUserOrgans().values().stream().map( UserOrgan::getUberonId ).collect( Collectors.toSet() ) ) ) .collect( Collectors.toSet() ); } + private Set organUberonIdsFromOrgans( Collection organs ) { + if ( organs != null ) { + return organs.stream().map( Organ::getUberonId ).collect( Collectors.toSet() ); + } else { + return null; + } + } + @Override @Cacheable(cacheNames = "ubc.pavlab.rdp.stats", key = "#root.methodName") public long countResearchers() { diff --git a/src/test/java/ubc/pavlab/rdp/repositories/UserOrganRepositoryTest.java b/src/test/java/ubc/pavlab/rdp/repositories/UserOrganRepositoryTest.java index 495e1fee..8a1f38c8 100644 --- a/src/test/java/ubc/pavlab/rdp/repositories/UserOrganRepositoryTest.java +++ b/src/test/java/ubc/pavlab/rdp/repositories/UserOrganRepositoryTest.java @@ -22,9 +22,6 @@ public class UserOrganRepositoryTest { @Autowired TestEntityManager entityManager; - @Autowired - private UserRepository userRepository; - @Autowired private UserOrganRepository userOrganRepository; From fb835fdb0355d67ad599815e88d2f88d9c96306c Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 14:35:11 -0800 Subject: [PATCH 25/36] Fix anonymizeUser raising an AccessDeniedException on enabled users This does however not cover the case where a user disabled, the exception will still be raised. --- src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java | 2 ++ src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index cdc49cbb..06941793 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -273,6 +273,8 @@ public User anonymizeUser( User user ) { .id( 0 ) .anonymousId( UUID.randomUUID() ) .profile( profile ) + // FIXME: a disabled user will still cause an AccessDeniedException + .enabled( user.isEnabled() ) .build(); // TODO: check if this is leaking too much personal information anonymizedUser.getUserOrgans().putAll( user.getUserOrgans() ); diff --git a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java index 4261870c..28c991b0 100644 --- a/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/UserServiceImplTest.java @@ -1288,9 +1288,11 @@ public void anonymizeUser_thenReturnAnonymizedUser() { when( privacySettings.getDefaultLevel() ).thenReturn( 2 ); when( privacySettings.isEnableAnonymizedSearchResults() ).thenReturn( true ); User user = createUser( 1 ); + user.setEnabled( true ); user.getProfile().setPrivacyLevel( PrivacyLevelType.PRIVATE ); User anonymizedUser = userService.anonymizeUser( user ); assertThat( anonymizedUser ) + .hasFieldOrPropertyWithValue( "enabled", true ) .hasFieldOrPropertyWithValue( "email", null ) .hasFieldOrPropertyWithValue( "profile.privacyLevel", PrivacyLevelType.PUBLIC ); assertThat( anonymizedUser.getUserGenes() ).isEmpty(); From 53c761d49d3c9cf7782deac98131d3936b8d5b34 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Fri, 11 Feb 2022 22:02:29 -0800 Subject: [PATCH 26/36] Filter users by enabled before anonymyzing Add a bit of documentation to explicit the possible AccessDeniedException when providing an non-enabled user. --- .../pavlab/rdp/controllers/ApiController.java | 2 +- .../rdp/repositories/UserGeneRepository.java | 5 +++ .../rdp/repositories/UserRepository.java | 6 +++- .../rdp/services/UserGeneServiceImpl.java | 2 +- .../ubc/pavlab/rdp/services/UserService.java | 34 ++++++++++++++++++- .../pavlab/rdp/services/UserServiceImpl.java | 4 +-- 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java index ebdb03cb..c51a7121 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/ApiController.java @@ -116,7 +116,7 @@ public Object getUsers( @RequestHeader(value = HttpHeaders.AUTHORIZATION, requir checkAuth( authorizationHeader, auth ); if ( applicationSettings.getPrivacy().isEnableAnonymizedSearchResults() ) { final Authentication auth2 = SecurityContextHolder.getContext().getAuthentication(); - return userService.findAllNoAuth( pageable ) + return userService.findAllByIsEnabledNoAuth( pageable ) .map( user -> permissionEvaluator.hasPermission( auth2, user, "read" ) ? user : userService.anonymizeUser( user ) ) .map( user -> initUser( user, locale ) ); diff --git a/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java index 5772cfe4..45c31544 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/UserGeneRepository.java @@ -32,6 +32,11 @@ public interface UserGeneRepository extends JpaRepository { @Query("select count(distinct user) FROM UserGene") Integer countDistinctUser(); + /** + * Find all genes from enabled users. + */ + Page findAllByUserEnabled( Pageable pageable ); + /** * Select all user genes that fall within a given privacy level. *

diff --git a/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java b/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java index b32c4f0d..c89ee5b1 100644 --- a/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java +++ b/src/main/java/ubc/pavlab/rdp/repositories/UserRepository.java @@ -1,6 +1,5 @@ package ubc.pavlab.rdp.repositories; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -29,6 +28,11 @@ public interface UserRepository extends JpaRepository { long countByProfilePrivacyLevel( PrivacyLevelType aPublic ); + /** + * Find all enabled users. + */ + Page findAllByEnabled( Pageable pageable ); + Page findAllByProfilePrivacyLevel( PrivacyLevelType privacyLevel, Pageable pageable ); @Query("select user from User user left join fetch user.roles where lower(user.email) = lower(:email)") diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 90c75ac3..7ed0ea05 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -73,7 +73,7 @@ public class UserGeneServiceImpl implements UserGeneService { @Override public Page findAllNoAuth( Pageable pageable ) { - return userGeneRepository.findAll( pageable ); + return userGeneRepository.findAllByUserEnabled( pageable ); } @Override diff --git a/src/main/java/ubc/pavlab/rdp/services/UserService.java b/src/main/java/ubc/pavlab/rdp/services/UserService.java index e49c357e..408a2748 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserService.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserService.java @@ -48,8 +48,40 @@ public interface UserService { User findUserByAccessTokenNoAuth( String accessToken ) throws TokenException; + /** + * Anonymize the given user. + *

+ * The {@link User#getId()} is replaced by zero, an {@link User#getAnonymousId()} is generated and the following + * fields are exposed in the returned object: researcher categories and organ systems. + *

+ * The original user is stored in a cache so that it can be retrieved by {@link #findUserByAnonymousIdNoAuth(UUID)} + * using the anonymized ID. + *

+ * Note: when using this, ensure that the user is enabled as per {@link User#isEnabled()}, otherwise a + * {@link org.springframework.security.access.AccessDeniedException} will be raised. That is because a non-enabled + * user will not satisfy the 'read' permission for any user as defined in {@link ubc.pavlab.rdp.security.PermissionEvaluatorImpl} + * and {@link PrivacyService#checkUserCanSee(User, UserContent)}. + * + * @throws org.springframework.security.access.AccessDeniedException if the user is not enabled + */ User anonymizeUser( User user ); + /** + * Anonymize the given gene. + *

+ * The {@link UserGene#getUser()} is anonymized in the process as per {@link #anonymizeUser(User)}. the {@link UserGene#getId()} + * is set to zero, an {@link UserGene#getAnonymousId()} is generated. The following fields are preserved in the + * returned object: gene ID, symbol, name taxon, modification date, tier. + *

+ * The original gene is stored in a cache so that it can be retrieved by {@link #findUserGeneByAnonymousIdNoAuth(UUID)} + * using the anonymized ID. + *

+ * Note: when using this, ensure that the user associated to the gene is enabled as per {@link User#isEnabled()}, + * otherwise a {@link org.springframework.security.access.AccessDeniedException} will be raised. + * + * @throws org.springframework.security.access.AccessDeniedException if the corresponding user of the gene is not + * enabled + */ UserGene anonymizeUserGene( UserGene userGene ); void revokeAccessToken( AccessToken accessToken ); @@ -60,7 +92,7 @@ public interface UserService { Collection findAll(); - Page findAllNoAuth( Pageable pageable ); + Page findAllByIsEnabledNoAuth( Pageable pageable ); Page findAllByPrivacyLevel( PrivacyLevelType privacyLevel, Pageable pageable ); diff --git a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java index 06941793..8ad2c4c7 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserServiceImpl.java @@ -328,8 +328,8 @@ public Collection findAll() { } @Override - public Page findAllNoAuth( Pageable pageable ) { - return userRepository.findAll( pageable ); + public Page findAllByIsEnabledNoAuth( Pageable pageable ) { + return userRepository.findAllByEnabled( pageable ); } @Override From 6eea007ed56e009397f97b04606585340a6ed768 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 09:08:27 -0800 Subject: [PATCH 27/36] Update spring-core to 4.3.30 --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 5db2d732..e052461f 100644 --- a/pom.xml +++ b/pom.xml @@ -176,6 +176,7 @@ 1.8 8.0.28 + 4.3.30.RELEASE 1.2.9 8.5.73 From d551a3861cb73c33e1ae784afcd3a860824ea13e Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 09:08:41 -0800 Subject: [PATCH 28/36] Update Tomcat to 8.5.75 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e052461f..0affe240 100644 --- a/pom.xml +++ b/pom.xml @@ -179,7 +179,7 @@ 4.3.30.RELEASE 1.2.9 - 8.5.73 + 8.5.75 From 00de2b187027d66c58123822c282a5be22d2a550 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 09:46:11 -0800 Subject: [PATCH 29/36] Mark loadUserByUsername transaction as read-only --- .../java/ubc/pavlab/rdp/services/UserDetailsServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/UserDetailsServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserDetailsServiceImpl.java index adeca100..b933a5c3 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserDetailsServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserDetailsServiceImpl.java @@ -19,7 +19,7 @@ public class UserDetailsServiceImpl implements UserDetailsService { private UserRepository userRepository; @Override - @Transactional + @Transactional(readOnly = true) public UserPrinciple loadUserByUsername( String email ) { User user = userRepository.findByEmailIgnoreCase( email ); if ( user == null ) { From 966c23dfb90c21c2344bae0d709e4674365e60fb Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 09:49:54 -0800 Subject: [PATCH 30/36] Cleanup imports in controllers --- .../java/ubc/pavlab/rdp/controllers/LoginController.java | 1 - .../ubc/pavlab/rdp/controllers/PasswordController.java | 8 -------- 2 files changed, 9 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java index 4c375e6f..b71a5f80 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/LoginController.java @@ -7,7 +7,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; -import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/ubc/pavlab/rdp/controllers/PasswordController.java b/src/main/java/ubc/pavlab/rdp/controllers/PasswordController.java index e7c6cd21..7b6613e7 100644 --- a/src/main/java/ubc/pavlab/rdp/controllers/PasswordController.java +++ b/src/main/java/ubc/pavlab/rdp/controllers/PasswordController.java @@ -1,32 +1,24 @@ package ubc.pavlab.rdp.controllers; -import ch.qos.logback.core.joran.spi.EventPlayer; import lombok.extern.apachecommons.CommonsLog; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; -import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; -import ubc.pavlab.rdp.events.OnUserPasswordResetEvent; import ubc.pavlab.rdp.exception.TokenException; import ubc.pavlab.rdp.model.PasswordReset; -import ubc.pavlab.rdp.model.PasswordResetToken; import ubc.pavlab.rdp.model.User; import ubc.pavlab.rdp.model.UserPrinciple; -import ubc.pavlab.rdp.services.EmailService; import ubc.pavlab.rdp.services.UserService; -import javax.mail.MessagingException; import javax.validation.Valid; -import java.text.MessageFormat; import java.util.Locale; /** From c649a79b8bac9d0a59bc69bbe860489e310463b0 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:10:31 -0800 Subject: [PATCH 31/36] Fallback on the taxon common name if ordering is null --- src/main/java/ubc/pavlab/rdp/model/User.java | 3 ++- src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/model/User.java b/src/main/java/ubc/pavlab/rdp/model/User.java index f406c466..464c842d 100644 --- a/src/main/java/ubc/pavlab/rdp/model/User.java +++ b/src/main/java/ubc/pavlab/rdp/model/User.java @@ -164,7 +164,8 @@ public Set getTaxons() { .map( UserGene::getTaxon ) // taxon are ordered by the ordering field, however the ordering field is not set for remote users // because it is ignored in JSON serialization - .sorted( Comparator.comparing( Taxon::getOrdering, Comparator.nullsLast( Comparator.naturalOrder() ) ) ) + .sorted( Comparator.comparing( Taxon::getOrdering, Comparator.nullsLast( Comparator.naturalOrder() ) ) + .thenComparing( Taxon::getCommonName ) ) .collect( Collectors.toCollection( LinkedHashSet::new ) ); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 7ed0ea05..6f28fe7e 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -142,6 +142,7 @@ public List handleGeneSearch( Gene gene, Set tiers, Taxon or return results.stream() .sorted( comparing( UserGene::getAnonymousId, nullsFirst( naturalOrder() ) ) .thenComparing( ug -> ug.getTaxon().getOrdering(), Comparator.nullsLast( naturalOrder() ) ) + .thenComparing( ug -> ug.getTaxon().getCommonName() ) .thenComparing( UserGene::getTier ) .thenComparing( ug -> ug.getUser().getProfile().getFullName() ) ) .collect( Collectors.toList() ); // we need to preserve the search order From f16e6397aa1345f098e8a72c2a73730d4a120515 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:10:58 -0800 Subject: [PATCH 32/36] Use taxon ordering for populating remote user genes Unknown or non-enabled taxon are not populated and will fallback on the common name. --- .../rdp/services/RemoteResourceServiceImpl.java | 13 +++++++++++-- .../rdp/services/RemoteResourceServiceTest.java | 3 +++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 49301d48..0c1fdb1d 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -59,6 +59,9 @@ public class RemoteResourceServiceImpl implements RemoteResourceService { @Autowired private AsyncRestTemplate asyncRestTemplate; + @Autowired + private TaxonService taxonService; + @Override @Cacheable(value = "ubc.pavlab.rdp.services.RemoteResourceService.apiVersionByRemoteHostAuthority", key = "#remoteHost.authority") public String getApiVersion( URI remoteHost ) throws RemoteException { @@ -125,8 +128,14 @@ public Collection findGenesBySymbol( String symbol, Taxon taxon, Set g.setUser( g.getRemoteUser() ) ); + Map taxonOrderingById = taxonService.findByActiveTrue().stream() + .collect( Collectors.toMap( Taxon::getId, Taxon::getOrdering ) ); + for ( UserGene g : intlUsergenes ) { + // add back-reference to user + g.setUser( g.getRemoteUser() ); + // populate taxon ordering + g.getTaxon().setOrdering( taxonOrderingById.getOrDefault( g.getTaxon().getId(), null ) ); + } return intlUsergenes; } diff --git a/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java b/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java index 6abe3f63..bd698550 100644 --- a/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java @@ -74,6 +74,9 @@ public ObjectMapper objectMapper() { @MockBean private RoleRepository roleRepository; + @MockBean + private TaxonService taxonService; + @Autowired private ObjectMapper objectMapper; From b61ce6c2c79d410b7cec72b21b45c1ceb027953b Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:24:27 -0800 Subject: [PATCH 33/36] Sort genes in RemoteResourceService While individual results from partner might be sorted, they are not sorted as a whole. --- .../rdp/services/RemoteResourceServiceImpl.java | 10 +++++++++- .../ubc/pavlab/rdp/services/UserGeneService.java | 7 +++---- .../pavlab/rdp/services/UserGeneServiceImpl.java | 15 ++++++++++----- .../rdp/services/RemoteResourceServiceTest.java | 3 +++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 0c1fdb1d..14500275 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -34,6 +34,8 @@ import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; +import static java.util.Comparator.*; +import static java.util.Comparator.naturalOrder; import static java.util.function.Function.identity; @Service("RemoteResourceService") @@ -53,6 +55,9 @@ public class RemoteResourceServiceImpl implements RemoteResourceService { @Autowired private UserService userService; + @Autowired + private UserGeneService userGeneService; + @Autowired private RoleRepository roleRepository; @@ -136,7 +141,10 @@ public Collection findGenesBySymbol( String symbol, Taxon taxon, Set handleGeneSearch( Gene gene, Set tiers, Taxon orthologTaxon, Set researcherPositions, Collection researcherTypes, Collection organs ); + Comparator getUserGeneComparator(); + void updateUserGenes(); } diff --git a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java index 6f28fe7e..484b8e1d 100644 --- a/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/UserGeneServiceImpl.java @@ -140,11 +140,7 @@ public List handleGeneSearch( Gene gene, Set tiers, Taxon or results = handleGeneSearchInternal( gene, tiers, orthologTaxon, researcherPositions, researcherCategories, organs ); } return results.stream() - .sorted( comparing( UserGene::getAnonymousId, nullsFirst( naturalOrder() ) ) - .thenComparing( ug -> ug.getTaxon().getOrdering(), Comparator.nullsLast( naturalOrder() ) ) - .thenComparing( ug -> ug.getTaxon().getCommonName() ) - .thenComparing( UserGene::getTier ) - .thenComparing( ug -> ug.getUser().getProfile().getFullName() ) ) + .sorted( getUserGeneComparator() ) .collect( Collectors.toList() ); // we need to preserve the search order } @@ -183,6 +179,15 @@ private Set handleOrthologSearchInternal( Gene gene, Set tie .collect( Collectors.toSet() ); } + @Override + public Comparator getUserGeneComparator() { + return comparing( UserGene::getAnonymousId, nullsFirst( naturalOrder() ) ) + .thenComparing( ug -> ug.getTaxon().getOrdering(), Comparator.nullsLast( naturalOrder() ) ) + .thenComparing( ug -> ug.getTaxon().getCommonName() ) + .thenComparing( UserGene::getTier ) + .thenComparing( ug -> ug.getUser().getProfile().getFullName() ); + } + @Override @Transactional public void updateUserGenes() { diff --git a/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java b/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java index bd698550..1951ca7f 100644 --- a/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java +++ b/src/test/java/ubc/pavlab/rdp/services/RemoteResourceServiceTest.java @@ -71,6 +71,9 @@ public ObjectMapper objectMapper() { @MockBean private UserService userService; + @MockBean + private UserGeneService userGeneService; + @MockBean private RoleRepository roleRepository; From aac022906a7d7a193b638f580fa2926bc6acacd5 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:41:27 -0800 Subject: [PATCH 34/36] Fix wrong locale for sendResetTokenMessage --- src/main/java/ubc/pavlab/rdp/services/EmailServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/EmailServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/EmailServiceImpl.java index f081821d..21f746c1 100644 --- a/src/main/java/ubc/pavlab/rdp/services/EmailServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/EmailServiceImpl.java @@ -113,7 +113,7 @@ public Future sendResetTokenMessage( User user, PasswordResetToken token, // password reset always go through the primary email InternetAddress to = new InternetAddress( user.getEmail() ); - String shortName = messageSource.getMessage( "rdp.site.shortname", new String[]{ siteSettings.getHostUri().toString() }, Locale.getDefault() ); + String shortName = messageSource.getMessage( "rdp.site.shortname", new String[]{ siteSettings.getHostUri().toString() }, locale ); String subject = messageSource.getMessage( "EmailService.sendResetTokenMessage.subject", new String[]{ shortName }, locale ); String content = messageSource.getMessage( "EmailService.sendResetTokenMessage", new String[]{ user.getProfile().getName(), url, dateTimeFormatter.format( token.getExpiryDate().toInstant() ) }, locale ); From 47d7751104c73acefbd66a70e08c484afc0ee55f Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:44:36 -0800 Subject: [PATCH 35/36] Fix warnings fom SonarQube Remove needless boolean boxing when checking if a user can update some content. Use == on enum instances. --- .../ubc/pavlab/rdp/services/PrivacyServiceImpl.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java index 5913fe71..e22909e2 100644 --- a/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/PrivacyServiceImpl.java @@ -73,7 +73,7 @@ public boolean checkCurrentUserCanSearch( boolean international ) { @Override public boolean checkUserCanUpdate( User user, UserContent userContent ) { // only admins or rightful owner can update user content - return user.getRoles().contains( getAdminRole() ) || userContent.getOwner().map( u -> u.equals( user ) ).orElse( false ); + return user.getRoles().contains( getAdminRole() ) || userContent.getOwner().filter( user::equals ).isPresent(); } private boolean checkUserCanSeeOtherUserContentWithPrivacyLevel( User currentUser, User otherUser, PrivacyLevelType privacyLevel ) { @@ -98,10 +98,10 @@ private boolean checkUserCanSeeOtherUserContentWithPrivacyLevel( User currentUse // Either the user is looking at himself, or the user is public, or shared with registered users - check for any logged-in user, or private - check for admin; If logged-in user is admin, we have to // check whether this user is the designated actor for the authenticated remote search, in which case we have to check for remote search privileges on the user. return otherUser.equals( currentUser ) // User is looking at himself - || ( privacyLevel.equals( PrivacyLevelType.PUBLIC ) ) // Data is public - || ( privacyLevel.equals( PrivacyLevelType.SHARED ) && currentUser != null && !isRemoteSearchUser( currentUser ) )// data is accessible for registerd users and there is a user logged in who is not the remote admin - || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && !isRemoteSearchUser( currentUser ) )// data is private and there is an admin logged in who is not the remote search user - || ( privacyLevel.equals( PrivacyLevelType.PRIVATE ) && currentUser != null && currentUser.getRoles().contains( getServiceAccountRole() ) && !isRemoteSearchUser( currentUser ) ) // user is a service account + || ( privacyLevel == PrivacyLevelType.PUBLIC ) // Data is public + || ( privacyLevel == PrivacyLevelType.SHARED && currentUser != null && !isRemoteSearchUser( currentUser ) )// data is accessible for registerd users and there is a user logged in who is not the remote admin + || ( privacyLevel == PrivacyLevelType.PRIVATE && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && !isRemoteSearchUser( currentUser ) )// data is private and there is an admin logged in who is not the remote search user + || ( privacyLevel == PrivacyLevelType.PRIVATE && currentUser != null && currentUser.getRoles().contains( getServiceAccountRole() ) && !isRemoteSearchUser( currentUser ) ) // user is a service account || ( profile.isShared() && currentUser != null && currentUser.getRoles().contains( getAdminRole() ) && isRemoteSearchUser( currentUser ) ); // data is designated as remotely shared and there is an admin logged in who is the remote search user } From f7782aea4d3d2c1933da169c397ddae962bf3154 Mon Sep 17 00:00:00 2001 From: Guillaume Poirier-Morency Date: Mon, 14 Feb 2022 10:51:20 -0800 Subject: [PATCH 36/36] Fix warning about thread interruption in RemoteResourceService --- .../rdp/services/RemoteResourceServiceImpl.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java index 14500275..a55d2b6d 100644 --- a/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java +++ b/src/main/java/ubc/pavlab/rdp/services/RemoteResourceServiceImpl.java @@ -87,8 +87,11 @@ public String getApiVersion( URI remoteHost ) throws RemoteException { } else { return openAPI.getInfo().getVersion(); } - } catch ( InterruptedException | ExecutionException e ) { + } catch ( ExecutionException e ) { throw new RemoteException( MessageFormat.format( "Unsuccessful response received for {0}.", uri ), e ); + } catch ( InterruptedException e ) { + Thread.currentThread().interrupt(); + throw new RemoteException( MessageFormat.format( "A thread was interrupted while waiting for {0} response.", uri ), e ); } } @@ -190,8 +193,11 @@ private User getUserByUri( URI uri ) throws RemoteException { User user = responseEntity.getBody(); initUser( user ); return user; - } catch ( InterruptedException | ExecutionException e ) { + } catch ( ExecutionException e ) { throw new RemoteException( MessageFormat.format( "Unsuccessful response received for {0}.", uri ), e ); + } catch ( InterruptedException e ) { + Thread.currentThread().interrupt(); + throw new RemoteException( MessageFormat.format( "A thread was interrupted while waiting for {0} response.", uri ), e ); } } @@ -213,9 +219,13 @@ private Collection getRemoteEntities( Class arrCls, String path, Mul .map( uriAndFuture -> { try { return uriAndFuture.getRight().get( applicationSettings.getIsearch().getRequestTimeout(), TimeUnit.SECONDS ); - } catch ( InterruptedException | ExecutionException | TimeoutException e ) { + } catch ( ExecutionException | TimeoutException e ) { log.error( MessageFormat.format( "Unsuccessful response received for {0}.", uriAndFuture.getLeft() ), e ); return null; + } catch ( InterruptedException e ) { + Thread.currentThread().interrupt(); + log.error( MessageFormat.format( "A thread was interrupted while waiting for {0} response.", uriAndFuture.getLeft() ), e ); + return null; } } ) .filter( Objects::nonNull )