From d9e8fd00d81056a0cb63199e300e48b6489aeab6 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 28 Oct 2019 17:45:00 +0100
Subject: [PATCH] Search functionality

---
 README.md                                     | 18 ++++
 gms-ui/src/App.vue                            | 37 +++++----
 .../api/mock/data/openUserSearchResult.json   | 13 +++
 gms-ui/src/api/mock/data/search.json          | 20 +++++
 gms-ui/src/api/mock/index.js                  |  8 ++
 gms-ui/src/api/server/index.js                | 27 ++++++
 .../src/components/GenericSearchResults.vue   | 54 ++++++++++++
 gms-ui/src/components/GroupsPanel.vue         | 17 +---
 gms-ui/src/components/TopMenu.vue             | 29 +++++--
 gms-ui/src/components/UserSearchResult.vue    | 71 ++++++++++++++++
 gms-ui/src/main.js                            |  4 +-
 gms-ui/src/store.js                           | 53 +++++++++++-
 .../it/inaf/ia2/gms/authn/SecurityConfig.java |  7 ++
 .../ia2/gms/model/response/UserGroup.java     | 25 ++++++
 .../gms/model/response/UserPermission.java    | 16 ++++
 .../model/response/UserSearchResponse.java    | 14 ++--
 .../ia2/gms/service/GroupNameService.java     | 81 ++++++++++++++++++
 .../inaf/ia2/gms/service/SearchService.java   | 67 ++++++++++++---
 .../ia2/gms/service/GroupNameServiceTest.java | 82 +++++++++++++++++++
 .../ia2/gms/service/SearchServiceTest.java    | 37 ++++++++-
 20 files changed, 618 insertions(+), 62 deletions(-)
 create mode 100644 gms-ui/src/api/mock/data/openUserSearchResult.json
 create mode 100644 gms-ui/src/api/mock/data/search.json
 create mode 100644 gms-ui/src/components/GenericSearchResults.vue
 create mode 100644 gms-ui/src/components/UserSearchResult.vue
 create mode 100644 gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java
 create mode 100644 gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java
 create mode 100644 gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java
 create mode 100644 gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java

diff --git a/README.md b/README.md
index 7b8b824..31c6305 100644
--- a/README.md
+++ b/README.md
@@ -19,3 +19,21 @@ To build the image:
 To run:
 
     docker run --env-file docker-env -d -p 8081:8081 -i -t gms:latest
+
+## Developer notes
+
+Backend and frontend are 2 separate applications:
+
+* the backend is the Maven application in the gms folder, based on Java and Spring Boot;
+* the frontend is the npm application is the gms-ui folder, based on Vue.js.
+
+The Maven application automatically packs the Vue.js products inside the final jar, however the frontend application can be tested isolatedly running `npm run serve` in order to take advantage of the npm autoreload functionalities.
+
+By default http calls are mocked inside the Vue.js application.
+In order to rely on real server calls edit the .env.development file in this way:
+
+    VUE_APP_API_CLIENT = 'server'
+    VUE_APP_API_BASE_URL = 'http://localhost:8081/gms/'
+
+This assumes that your backend runs on 8081 port (with dev profile active, in order to enable the CORS policy) and the frontend runs on 8080 port.
+First, do the login using the application running on the 8081 port, then you can access the frontend on the 8080.
diff --git a/gms-ui/src/App.vue b/gms-ui/src/App.vue
index d47d343..89678b2 100644
--- a/gms-ui/src/App.vue
+++ b/gms-ui/src/App.vue
@@ -1,44 +1,53 @@
 <template>
-  <div id="app" v-if="model">
-    <TopMenu v-bind:user="model.user" />
-    <div class="container">
-      <Main />
-    </div>
-    <div id="loading" v-if="loading">
-      <div id="spinner-wrapper">
-        <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner>
-      </div>
+<div id="app" v-if="model">
+  <TopMenu v-bind:user="model.user" />
+  <div class="container">
+    <Main v-if="page === 'main'" />
+    <GenericSearchResults v-if="page === 'search'" />
+    <UserSearchResult v-if="page === 'userSearch'" />
+  </div>
+  <div id="loading" v-if="loading">
+    <div id="spinner-wrapper">
+      <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner>
     </div>
   </div>
+</div>
 </template>
 
 <script>
 import TopMenu from './components/TopMenu.vue';
 import Main from './components/Main.vue';
-import { mapState } from 'vuex';
+import GenericSearchResults from './components/GenericSearchResults.vue';
+import UserSearchResult from './components/UserSearchResult.vue';
+import {
+  mapState
+} from 'vuex';
 import client from 'api-client';
 
 export default {
   name: 'app',
   components: {
     TopMenu,
-    Main
+    Main,
+    GenericSearchResults,
+    UserSearchResult
   },
   computed: mapState({
     model: state => state.model,
     input: state => state.input,
-    loading: state => state.loading
+    loading: state => state.loading,
+    page: state => state.page
   }),
   mounted: function() {
     var self = this;
-    document.addEventListener('apiError', function (event) {
+    document.addEventListener('apiError', function(event) {
       self.$bvToast.toast(event.message, {
         title: "Error",
         variant: 'danger',
         solid: true
       });
     });
-    document.addEventListener('loading', function (event) {
+    document.addEventListener('loading', function(event) {
       self.$store.commit('setLoading', event.value);
     });
 
diff --git a/gms-ui/src/api/mock/data/openUserSearchResult.json b/gms-ui/src/api/mock/data/openUserSearchResult.json
new file mode 100644
index 0000000..65d38ea
--- /dev/null
+++ b/gms-ui/src/api/mock/data/openUserSearchResult.json
@@ -0,0 +1,13 @@
+{
+  "groups": [{
+    "id": "744e38e8f6d04e4e9418ae5f131c9b6b",
+    "name": "LBT",
+    "path": "744e38e8f6d04e4e9418ae5f131c9b6b"
+  }],
+  "permissions": [{
+    "userId": "4",
+    "groupId": "744e38e8f6d04e4e9418ae5f131c9b6b",
+    "permission": "VIEW_MEMBERS",
+    "groupPath": "744e38e8f6d04e4e9418ae5f131c9b6b"
+  }]
+}
diff --git a/gms-ui/src/api/mock/data/search.json b/gms-ui/src/api/mock/data/search.json
new file mode 100644
index 0000000..d81df04
--- /dev/null
+++ b/gms-ui/src/api/mock/data/search.json
@@ -0,0 +1,20 @@
+{
+  "items": [{
+      "id": "4",
+      "type": "USER",
+      "label": "Name Surname"
+    },
+    {
+      "id": "group_id",
+      "type": "GROUP",
+      "label": "Group 1"
+    }
+  ],
+  "currentPage": 1,
+  "links": [1],
+  "totalItems": 2,
+  "pageSize": 20,
+  "totalPages": 1,
+  "hasPreviousPages": false,
+  "hasFollowingPages": false
+}
diff --git a/gms-ui/src/api/mock/index.js b/gms-ui/src/api/mock/index.js
index 85dfeb0..5938d36 100644
--- a/gms-ui/src/api/mock/index.js
+++ b/gms-ui/src/api/mock/index.js
@@ -5,6 +5,8 @@ import membersPanel from './data/membersPanel';
 import permissionsPanel from './data/permissionsPanel';
 import searchUser from './data/searchUser';
 import permission from './data/permission';
+import search from './data/search';
+import openUserSearchResult from './data/openUserSearchResult';
 
 const fetch = (mockData, time = 0) => {
   return new Promise((resolve) => {
@@ -56,5 +58,11 @@ export default {
   },
   removeMember() {
     return fetch(membersPanel, 500);
+  },
+  search() {
+    return fetch(search, 500);
+  },
+  openUserSearchResult() {
+    return fetch(openUserSearchResult, 500);
   }
 }
diff --git a/gms-ui/src/api/server/index.js b/gms-ui/src/api/server/index.js
index b64e8ae..b05cca5 100644
--- a/gms-ui/src/api/server/index.js
+++ b/gms-ui/src/api/server/index.js
@@ -275,5 +275,32 @@ export default {
         'Accept': 'application/json',
       }
     });
+  },
+  search(input) {
+    let url = BASE_API_URL + 'search?query=' + input.genericSearch.filter +
+      '&page=' + input.genericSearch.paginatorPage + '&pageSize=' + input.genericSearch.paginatorPageSize;
+
+    return apiRequest(url, {
+      method: 'GET',
+      cache: 'no-cache',
+      credentials: 'include',
+      headers: {
+        'Content-Type': 'application/json',
+        'Accept': 'application/json',
+      }
+    });
+  },
+  openUserSearchResult(userId) {
+    let url = BASE_API_URL + 'search/user/' + userId;
+
+    return apiRequest(url, {
+      method: 'GET',
+      cache: 'no-cache',
+      credentials: 'include',
+      headers: {
+        'Content-Type': 'application/json',
+        'Accept': 'application/json',
+      }
+    });
   }
 };
diff --git a/gms-ui/src/components/GenericSearchResults.vue b/gms-ui/src/components/GenericSearchResults.vue
new file mode 100644
index 0000000..d7fdade
--- /dev/null
+++ b/gms-ui/src/components/GenericSearchResults.vue
@@ -0,0 +1,54 @@
+<template>
+<div class="mt-sm-3">
+  <div>
+    <p>Search results:</p>
+    <b-list-group v-for="item in model.genericSearchResults.items" v-bind:key="item.id">
+      <b-list-group-item href="#" v-on:click="openSearchResult(item)">
+        <span class="float-left">
+          <font-awesome-icon icon="folder" v-if="item.type === 'GROUP'"></font-awesome-icon>
+          <font-awesome-icon icon="user" v-if="item.type === 'USER'"></font-awesome-icon>
+          {{item.label}}
+        </span>
+      </b-list-group-item>
+    </b-list-group>
+    <Paginator :paginatedPanel="model.genericSearchResults" :onUpdate="updateSearchResults" />
+  </div>
+</div>
+</template>
+
+<script>
+import client from 'api-client';
+import Paginator from './Paginator.vue';
+import {
+  mapState
+} from 'vuex';
+
+export default {
+  name: 'GenericSearchResults',
+  components: {
+    Paginator
+  },
+  computed: mapState({
+    model: state => state.model,
+    input: state => state.input
+  }),
+  methods: {
+    openSearchResult: function(result) {
+      switch (result.type) {
+        case 'GROUP':
+          this.$store.commit('openGroup', result.id);
+          break;
+        case 'USER':
+          client.openUserSearchResult(result.id)
+            .then(model => {
+              this.$store.commit('displayUserSearchResults', [result.label, model]);
+            });
+          break;
+      }
+    },
+    updateSearchResults: function() {
+
+    }
+  }
+}
+</script>
diff --git a/gms-ui/src/components/GroupsPanel.vue b/gms-ui/src/components/GroupsPanel.vue
index 4522e71..a30ab4d 100644
--- a/gms-ui/src/components/GroupsPanel.vue
+++ b/gms-ui/src/components/GroupsPanel.vue
@@ -50,24 +50,9 @@ export default {
     model: state => state.model,
     input: state => state.input
   }),
-  data: function() {
-    return {
-      groupFilter: ''
-    };
-  },
   methods: {
     openGroup: function(group) {
-      this.$store.state.input.selectedGroupId = group.groupId;
-      this.$store.state.input.searchFilter = null;
-      client.fetchGroupsTab(this.input)
-        .then(model => {
-          if (model.groupsPanel.items.length > 0) {
-            this.$store.commit('updateGroups', model);
-          } else {
-            // If there are no subgroups show the members panel
-            this.$store.commit('setTabIndex', '1');
-          }
-        });
+      this.$store.commit('openGroup', group.groupId);
     },
     openRenameGroupModal: function(group) {
       this.$refs.renameGroupModal.openRenameGroupModal(group);
diff --git a/gms-ui/src/components/TopMenu.vue b/gms-ui/src/components/TopMenu.vue
index f3f91ca..8a6677d 100644
--- a/gms-ui/src/components/TopMenu.vue
+++ b/gms-ui/src/components/TopMenu.vue
@@ -1,7 +1,7 @@
 <template>
 <div>
   <b-navbar toggleable="lg" type="dark" variant="info">
-    <b-navbar-brand href="#" class="d-none d-md-block">Group Membership Service</b-navbar-brand>
+    <b-navbar-brand href="#" class="d-none d-md-block" v-on:click="showMainPage">Group Membership Service</b-navbar-brand>
 
     <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
 
@@ -9,12 +9,10 @@
 
       <!-- Right aligned nav items -->
       <b-navbar-nav class="ml-auto">
-        <!--
         <b-nav-form>
-          <b-form-input size="sm" class="mr-sm-2" placeholder="Search"></b-form-input>
-          <b-button size="sm" class="my-2 my-sm-0" type="submit">Search</b-button>
+          <b-form-input size="sm" class="mr-sm-2" placeholder="Search" v-model="input.genericSearch.filter"></b-form-input>
+          <b-button size="sm" class="my-2 my-sm-0" type="button" v-on:click="genericSearch()">Search</b-button>
         </b-nav-form>
-        -->
         <b-nav-item-dropdown :text="user" right v-if="user">
           <b-dropdown-item href="logout">Logout</b-dropdown-item>
         </b-nav-item-dropdown>
@@ -25,10 +23,31 @@
 </template>
 
 <script>
+import client from 'api-client';
+import {
+  mapState
+} from 'vuex';
+
 export default {
   name: 'TopMenu',
   props: {
     user: String
+  },
+  computed: mapState({
+    input: state => state.input,
+  }),
+  methods: {
+    showMainPage() {
+      this.$store.commit('showMainPage');
+    },
+    genericSearch() {
+      this.input.genericSearch.page = 1;
+      this.input.genericSearch.pageSize = 20;
+      client.search(this.input)
+        .then(results => {
+          this.$store.commit('displaySearchResults', results);
+        });
+    }
   }
 }
 </script>
diff --git a/gms-ui/src/components/UserSearchResult.vue b/gms-ui/src/components/UserSearchResult.vue
new file mode 100644
index 0000000..a2b87c8
--- /dev/null
+++ b/gms-ui/src/components/UserSearchResult.vue
@@ -0,0 +1,71 @@
+<template>
+<div class="mt-sm-3" v-if="userLabel !== null">
+  <b-button variant="primary" class="float-right" v-on:click="back()">Back</b-button>
+  <h5>Results for <strong>{{userLabel}}</strong>:</h5>
+
+  <b-container class="mt-sm-5">
+    <b-row>
+      <b-col class="text-left">
+        <h5>Is member of</h5>
+        <div v-if="groups.length === 0">
+          No groups to show
+        </div>
+        <div v-if="groups.length > 0">
+          <ul>
+            <li v-for="group in groups" v-bind:key="group.groupId">
+              <a href="#" v-on:click="openGroup(group.groupId)">
+                {{group.groupCompleteName.join(' / ')}}
+              </a>
+            </li>
+          </ul>
+        </div>
+      </b-col>
+      <b-col v-if="permissions.length > 0">
+        <h5>Permissions</h5>
+        <table class="table table-striped">
+          <thead>
+            <tr>
+              <th>Group</th>
+              <th>Permission</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(p, rowIndex) in permissions" v-bind:key="rowIndex">
+              <td>
+                <a href="#" v-on:click="openGroup(p.groupId)">
+                  {{p.groupCompleteName.join(' / ')}}
+                </a>
+              </td>
+              <td>{{p.permission}}</td>
+            </tr>
+          </tbody>
+        </table>
+      </b-col>
+    </b-row>
+  </b-container>
+
+</div>
+</template>
+
+<script>
+import {
+  mapState
+} from 'vuex';
+
+export default {
+  name: 'UserSearchResult',
+  computed: mapState({
+    userLabel: state => state.model.userSearchResults.userLabel,
+    groups: state => state.model.userSearchResults.groups,
+    permissions: state => state.model.userSearchResults.permissions
+  }),
+  methods: {
+    back() {
+      this.$store.commit('displaySearchResults');
+    },
+    openGroup(groupId) {
+      this.$store.commit('openGroup', groupId);
+    }
+  }
+}
+</script>
diff --git a/gms-ui/src/main.js b/gms-ui/src/main.js
index 1d6dc7e..fe6307e 100644
--- a/gms-ui/src/main.js
+++ b/gms-ui/src/main.js
@@ -5,10 +5,10 @@ import store from './store.js'
 import './plugins/bootstrap-vue'
 import App from './App.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { faTrash, faEdit, faSpinner } from '@fortawesome/free-solid-svg-icons'
+import { faTrash, faEdit, faSpinner, faFolder, faUser } from '@fortawesome/free-solid-svg-icons'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
 
-library.add(faTrash, faEdit, faSpinner);
+library.add(faTrash, faEdit, faSpinner, faFolder, faUser);
 
 Vue.component('font-awesome-icon', FontAwesomeIcon);
 
diff --git a/gms-ui/src/store.js b/gms-ui/src/store.js
index 1525101..06f5ed3 100644
--- a/gms-ui/src/store.js
+++ b/gms-ui/src/store.js
@@ -2,6 +2,7 @@
 
 import Vue from 'vue';
 import Vuex from 'vuex';
+import client from 'api-client';
 
 Vue.use(Vuex);
 
@@ -14,7 +15,13 @@ export default new Vuex.Store({
       permissionsPanel: null,
       membersPanel: null,
       permission: null,
-      user: null
+      user: null,
+      genericSearchResults: [],
+      userSearchResults: {
+        userLabel: null,
+        groups: {},
+        permissions: {}
+      }
     },
     // values used to perform API calls
     input: {
@@ -23,9 +30,15 @@ export default new Vuex.Store({
       paginatorPage: 1,
       selectedTab: 'groups',
       tabIndex: 0,
-      searchFilter: null
+      searchFilter: null,
+      genericSearch: {
+        filter: null,
+        paginatorPage: 1,
+        paginatorPageSize: 20
+      }
     },
-    loading: false
+    loading: false,
+    page: 'main'
   },
   mutations: {
     updateHomePageModel(state, model) {
@@ -34,6 +47,22 @@ export default new Vuex.Store({
       this.state.model.permission = model.permission;
       this.state.model.user = model.user;
     },
+    openGroup(state, groupId) {
+      let input = this.state.input;
+      input.selectedGroupId = groupId;
+      input.searchFilter = null;
+      client.fetchGroupsTab(input)
+        .then(model => {
+          if (model.groupsPanel.items.length > 0) {
+            this.commit('setTabIndex', 0);
+            this.commit('updateGroups', model);
+          } else {
+            // If there are no subgroups show the members panel
+            this.commit('setTabIndex', 1);
+          }
+          this.commit('showMainPage');
+        });
+    },
     updateGroups(state, model) {
       this.state.model.breadcrumbs = model.breadcrumbs;
       this.state.model.groupsPanel = model.groupsPanel;
@@ -54,6 +83,24 @@ export default new Vuex.Store({
     },
     setLoading(state, loading) {
       this.state.loading = loading;
+    },
+    showMainPage(state) {
+      this.state.page = 'main';
+    },
+    displaySearchResults(state, results) {
+      this.state.page = 'search';
+      if (results) {
+        this.state.model.genericSearchResults = results;
+      }
+    },
+    updateSearchResults(state, results) {
+      this.state.model.genericSearchResults = results;
+    },
+    displayUserSearchResults(state, data) {
+      this.state.page = 'userSearch';
+      this.state.model.userSearchResults.userLabel = data[0];
+      this.state.model.userSearchResults.groups = data[1].groups;
+      this.state.model.userSearchResults.permissions = data[1].permissions;
     }
   },
   getters: {
diff --git a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java
index e77eaaf..04c3994 100644
--- a/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java
+++ b/gms/src/main/java/it/inaf/ia2/gms/authn/SecurityConfig.java
@@ -1,6 +1,8 @@
 package it.inaf.ia2.gms.authn;
 
 import java.util.Arrays;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
@@ -23,6 +25,8 @@ import org.springframework.web.filter.CorsFilter;
 @EnableOAuth2Sso
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
+    private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class);
+
     @Autowired
     private Environment env;
 
@@ -92,6 +96,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
     @Bean
     @Profile("dev")
     public FilterRegistrationBean corsFilter() {
+
+        LOG.warn("Development profile active: CORS filter enabled");
+
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
         CorsConfiguration config = new CorsConfiguration().applyPermitDefaultValues();
         config.addAllowedMethod(HttpMethod.PUT);
diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java
new file mode 100644
index 0000000..26fdd28
--- /dev/null
+++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserGroup.java
@@ -0,0 +1,25 @@
+package it.inaf.ia2.gms.model.response;
+
+import java.util.List;
+
+public class UserGroup {
+
+    private String groupId;
+    private List<String> groupCompleteName;
+
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    public List<String> getGroupCompleteName() {
+        return groupCompleteName;
+    }
+
+    public void setGroupCompleteName(List<String> groupCompleteName) {
+        this.groupCompleteName = groupCompleteName;
+    }
+}
diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java
new file mode 100644
index 0000000..892777c
--- /dev/null
+++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserPermission.java
@@ -0,0 +1,16 @@
+package it.inaf.ia2.gms.model.response;
+
+import it.inaf.ia2.gms.model.Permission;
+
+public class UserPermission extends UserGroup {
+
+    private Permission permission;
+
+    public Permission getPermission() {
+        return permission;
+    }
+
+    public void setPermission(Permission permission) {
+        this.permission = permission;
+    }
+}
diff --git a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java
index d09cc2b..0ec8567 100644
--- a/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java
+++ b/gms/src/main/java/it/inaf/ia2/gms/model/response/UserSearchResponse.java
@@ -1,27 +1,25 @@
 package it.inaf.ia2.gms.model.response;
 
-import it.inaf.ia2.gms.persistence.model.GroupEntity;
-import it.inaf.ia2.gms.persistence.model.PermissionEntity;
 import java.util.List;
 
 public class UserSearchResponse {
 
-    private List<GroupEntity> groups;
-    private List<PermissionEntity> permissions;
+    private List<UserGroup> groups;
+    private List<UserPermission> permissions;
 
-    public List<GroupEntity> getGroups() {
+    public List<UserGroup> getGroups() {
         return groups;
     }
 
-    public void setGroups(List<GroupEntity> groups) {
+    public void setGroups(List<UserGroup> groups) {
         this.groups = groups;
     }
 
-    public List<PermissionEntity> getPermissions() {
+    public List<UserPermission> getPermissions() {
         return permissions;
     }
 
-    public void setPermissions(List<PermissionEntity> permissions) {
+    public void setPermissions(List<UserPermission> permissions) {
         this.permissions = permissions;
     }
 }
diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java b/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java
new file mode 100644
index 0000000..20c04e9
--- /dev/null
+++ b/gms/src/main/java/it/inaf/ia2/gms/service/GroupNameService.java
@@ -0,0 +1,81 @@
+package it.inaf.ia2.gms.service;
+
+import it.inaf.ia2.gms.persistence.GroupsDAO;
+import it.inaf.ia2.gms.persistence.model.GroupEntity;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * Utility class for retrieving the complete names (including parents) from a
+ * set of group paths.
+ */
+@Service
+public class GroupNameService {
+
+    @Autowired
+    private GroupsDAO groupsDAO;
+
+    /**
+     * @param groupsIdPath map having group id as keys and group paths as values
+     * @return map having group id as keys and group names as values
+     */
+    public Map<String, List<String>> getNames(List<Map.Entry<String, String>> groupsIdPath) {
+
+        Set<String> allIdentifiers = new HashSet<>();
+        for (Map.Entry<String, String> entry : groupsIdPath) {
+            allIdentifiers.addAll(getIdentifiers(entry.getValue()));
+        }
+
+        Map<String, String> groupSingleNamesMap = getGroupSingleNamesMap(allIdentifiers);
+
+        Map<String, List<String>> groupCompleteNamesMap = new HashMap<>();
+        for (Map.Entry<String, String> entry : groupsIdPath) {
+            List<String> groupCompleteName = getGroupCompleteName(groupSingleNamesMap, entry.getValue());
+            groupCompleteNamesMap.put(entry.getKey(), groupCompleteName);
+        }
+
+        return groupCompleteNamesMap;
+    }
+
+    private Map<String, String> getGroupSingleNamesMap(Set<String> allIdentifiers) {
+        Map<String, String> groupNamesMap = new HashMap<>();
+        for (GroupEntity group : groupsDAO.findGroupsByIds(allIdentifiers)) {
+            groupNamesMap.put(group.getId(), group.getName());
+        }
+
+        return groupNamesMap;
+    }
+
+    private List<String> getGroupCompleteName(Map<String, String> groupNamesMap, String groupPath) {
+        List<String> names = new ArrayList<>();
+        if (groupPath.isEmpty()) {
+            names.add("Root");
+        } else {
+            List<String> identifiers = getIdentifiers(groupPath);
+            for (String groupId : identifiers) {
+                names.add(groupNamesMap.get(groupId));
+            }
+        }
+        return names;
+    }
+
+    /**
+     * Returns the list of all identifiers including parent ones.
+     */
+    private List<String> getIdentifiers(String groupPath) {
+        List<String> identifiers = new ArrayList<>();
+        if (!groupPath.isEmpty()) {
+            for (String id : groupPath.split(Pattern.quote("."))) {
+                identifiers.add(id);
+            }
+        }
+        return identifiers;
+    }
+}
diff --git a/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java b/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java
index f809a61..7aa9a38 100644
--- a/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java
+++ b/gms/src/main/java/it/inaf/ia2/gms/service/SearchService.java
@@ -4,6 +4,8 @@ import it.inaf.ia2.gms.model.Permission;
 import it.inaf.ia2.gms.model.response.PaginatedData;
 import it.inaf.ia2.gms.model.response.SearchResponseItem;
 import it.inaf.ia2.gms.model.response.SearchResponseType;
+import it.inaf.ia2.gms.model.response.UserGroup;
+import it.inaf.ia2.gms.model.response.UserPermission;
 import it.inaf.ia2.gms.model.response.UserSearchResponse;
 import it.inaf.ia2.gms.persistence.GroupsDAO;
 import it.inaf.ia2.gms.persistence.MembershipsDAO;
@@ -11,8 +13,10 @@ import it.inaf.ia2.gms.persistence.PermissionsDAO;
 import it.inaf.ia2.gms.persistence.model.GroupEntity;
 import it.inaf.ia2.gms.persistence.model.PermissionEntity;
 import it.inaf.ia2.gms.rap.RapClient;
+import java.util.AbstractMap.SimpleEntry;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -35,6 +39,9 @@ public class SearchService {
     @Autowired
     private MembershipsDAO membershipsDAO;
 
+    @Autowired
+    private GroupNameService groupNameService;
+
     /**
      * Generic search (both groups and users).
      */
@@ -91,30 +98,66 @@ public class SearchService {
      */
     public UserSearchResponse getUserSearchResult(String actorUserId, String targetUserId) {
 
-        List<GroupEntity> allGroups = membershipsDAO.getUserMemberships(targetUserId);
+        // Select only the information visible to the actor user
+        List<PermissionEntity> actorPermissions = permissionsDAO.findUserPermissions(actorUserId);
 
-        // Select only the groups visible to the user
-        List<PermissionEntity> permissions = permissionsDAO.findUserPermissions(actorUserId);
+        UserSearchResponse response = new UserSearchResponse();
+        response.setGroups(getUserGroups(targetUserId, actorPermissions));
+        response.setPermissions(getUserPermission(targetUserId, actorPermissions));
+
+        return response;
+    }
+
+    private List<UserGroup> getUserGroups(String targetUserId, List<PermissionEntity> actorPermissions) {
+
+        List<GroupEntity> allGroups = membershipsDAO.getUserMemberships(targetUserId);
 
-        List<GroupEntity> visibleGroups = new ArrayList<>();
+        // Select only groups visible to the actor user
+        List<Map.Entry<String, String>> visibleGroupsIdPath = new ArrayList<>();
         for (GroupEntity group : allGroups) {
 
-            PermissionUtils.getGroupPermission(group, permissions).ifPresent(permission -> {
-                visibleGroups.add(group);
+            PermissionUtils.getGroupPermission(group, actorPermissions).ifPresent(permission -> {
+                visibleGroupsIdPath.add(new SimpleEntry<>(group.getId(), group.getPath()));
             });
         }
 
-        UserSearchResponse response = new UserSearchResponse();
-        response.setGroups(visibleGroups);
+        return groupNameService.getNames(visibleGroupsIdPath).entrySet().stream()
+                .map(entry -> {
+                    UserGroup ug = new UserGroup();
+                    ug.setGroupId(entry.getKey());
+                    ug.setGroupCompleteName(entry.getValue());
+                    return ug;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private List<UserPermission> getUserPermission(String targetUserId, List<PermissionEntity> actorPermissions) {
+
+        List<UserPermission> permissions = new ArrayList<>();
 
         // Super-admin user is able to see also other user permissions
-        PermissionUtils.getGroupPermission(groupsService.getRoot(), permissions).ifPresent(permission -> {
+        PermissionUtils.getGroupPermission(groupsService.getRoot(), actorPermissions).ifPresent(permission -> {
             if (permission.equals(Permission.ADMIN)) {
-                List<PermissionEntity> targetUserPermissions = permissionsDAO.findUserPermissions(targetUserId);
-                response.setPermissions(targetUserPermissions);
+
+                Map<String, PermissionEntity> targetUserPermissions
+                        = permissionsDAO.findUserPermissions(targetUserId).stream()
+                                .collect(Collectors.toMap(PermissionEntity::getGroupId, p -> p));
+
+                List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>();
+                for (PermissionEntity p : targetUserPermissions.values()) {
+                    groupsIdPath.add(new SimpleEntry<>(p.getGroupId(), p.getGroupPath()));
+                }
+
+                for (Map.Entry<String, List<String>> entry : groupNameService.getNames(groupsIdPath).entrySet()) {
+                    UserPermission up = new UserPermission();
+                    up.setGroupId(entry.getKey());
+                    up.setGroupCompleteName(entry.getValue());
+                    up.setPermission(targetUserPermissions.get(entry.getKey()).getPermission());
+                    permissions.add(up);
+                }
             }
         });
 
-        return response;
+        return permissions;
     }
 }
diff --git a/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java b/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java
new file mode 100644
index 0000000..1ec9f9a
--- /dev/null
+++ b/gms/src/test/java/it/inaf/ia2/gms/service/GroupNameServiceTest.java
@@ -0,0 +1,82 @@
+package it.inaf.ia2.gms.service;
+
+import it.inaf.ia2.gms.persistence.GroupsDAO;
+import it.inaf.ia2.gms.persistence.model.GroupEntity;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import static org.junit.Assert.assertEquals;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GroupNameServiceTest {
+
+    @Mock
+    private GroupsDAO groupsDAO;
+
+    @InjectMocks
+    private GroupNameService groupNameService;
+
+    @Test
+    public void getNamesTest() {
+
+        mockGroupsDAO();
+
+        List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>();
+        groupsIdPath.add(new AbstractMap.SimpleEntry<>("def", "abc.def"));
+
+        Map<String, List<String>> names = groupNameService.getNames(groupsIdPath);
+        assertEquals(1, names.size());
+        assertEquals(2, names.get("def").size());
+        assertEquals("Group 1", names.get("def").get(0));
+        assertEquals("Group 2", names.get("def").get(1));
+    }
+
+    public void mockGroupsDAO() {
+
+        List<GroupEntity> groups = new ArrayList<>();
+
+        GroupEntity group1 = new GroupEntity();
+        group1.setId("abc");
+        group1.setName("Group 1");
+        group1.setPath("abc");
+        groups.add(group1);
+
+        GroupEntity group2 = new GroupEntity();
+        group2.setId("def");
+        group2.setName("Group 2");
+        group2.setPath("abc.def");
+        groups.add(group2);
+
+        when(groupsDAO.findGroupsByIds(any())).thenReturn(groups);
+    }
+
+    @Test
+    public void getRootTest() {
+
+        List<GroupEntity> groups = new ArrayList<>();
+
+        GroupEntity root = new GroupEntity();
+        root.setId("ROOT");
+        root.setName("Root");
+        root.setPath("");
+        groups.add(root);
+
+        when(groupsDAO.findGroupsByIds(any())).thenReturn(groups);
+
+        List<Map.Entry<String, String>> groupsIdPath = new ArrayList<>();
+        groupsIdPath.add(new AbstractMap.SimpleEntry<>("ROOT", ""));
+
+        Map<String, List<String>> names = groupNameService.getNames(groupsIdPath);
+        assertEquals(1, names.size());
+        assertEquals(1, names.get("ROOT").size());
+        assertEquals("Root", names.get("ROOT").get(0));
+    }
+}
diff --git a/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java b/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java
index 7d11078..b05d539 100644
--- a/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java
+++ b/gms/src/test/java/it/inaf/ia2/gms/service/SearchServiceTest.java
@@ -16,9 +16,11 @@ import it.inaf.ia2.gms.persistence.model.PermissionEntity;
 import it.inaf.ia2.gms.rap.RapClient;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import static org.mockito.ArgumentMatchers.any;
@@ -46,6 +48,9 @@ public class SearchServiceTest {
     @Mock
     private MembershipsDAO membershipsDAO;
 
+    @Mock
+    private GroupNameService groupNameService;
+
     @InjectMocks
     private SearchService searchService;
 
@@ -120,14 +125,42 @@ public class SearchServiceTest {
         when(permissionsDAO.findUserPermissions(eq("admin_id")))
                 .thenReturn(Collections.singletonList(adminPermission));
 
+        PermissionEntity targetUserPermission = new PermissionEntity();
+        targetUserPermission.setGroupId("group1_id");
+        targetUserPermission.setUserId("target_id");
+        targetUserPermission.setGroupPath("group1_id");
+        targetUserPermission.setPermission(Permission.MANAGE_MEMBERS);
+
+        when(permissionsDAO.findUserPermissions(eq("target_id")))
+                .thenReturn(Collections.singletonList(targetUserPermission));
+
         GroupEntity root = new GroupEntity();
         root.setId("ROOT");
         root.setName("Root");
         root.setPath("");
         when(groupsService.getRoot()).thenReturn(root);
 
+        when(groupNameService.getNames(any())).then(invocation -> {
+            Map<String, List<String>> result = new HashMap<>();
+            List<Map.Entry<String, String>> arg = invocation.getArgument(0);
+            for (Entry<String, String> entry : arg) {
+                List<String> names = new ArrayList<>();
+                switch (entry.getKey()) {
+                    case "ROOT":
+                        names.add("Root");
+                        break;
+                    case "group1_id":
+                        names.add("Group 1");
+                        break;
+                }
+                result.put(entry.getKey(), names);
+            }
+            return result;
+        });
+
         UserSearchResponse response = searchService.getUserSearchResult("admin_id", "target_id");
         assertEquals(1, response.getGroups().size());
-        assertNotNull(response.getPermissions());
+        assertEquals(1, response.getPermissions().size());
+        assertEquals(Permission.MANAGE_MEMBERS, response.getPermissions().get(0).getPermission());
     }
 }
-- 
GitLab