From 43628f258352d6de6ac8ae03b8255bd73037cf5b Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Tue, 16 Mar 2021 17:17:58 +0100
Subject: [PATCH] Used Jsoup for generating server-side HTML fragments

---
 vospace-ui-backend/pom.xml                    |   5 +
 .../ui/controller/NodesController.java        |  23 +-
 .../inaf/ia2/vospace/ui/service/NodeInfo.java |  20 +-
 .../ui/service/NodesHtmlGenerator.java        | 223 ++++++++++++++++++
 .../ia2/vospace/ui/service/NodesService.java  | 149 ------------
 .../ui/controller/NodesControllerTest.java    |  19 +-
 .../ia2/vospace/ui/service/NodeInfoTest.java  |   3 +-
 .../ui/service/NodesHtmlGeneratorTest.java    |  53 +++++
 8 files changed, 326 insertions(+), 169 deletions(-)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java
 delete mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
 create mode 100644 vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java

diff --git a/vospace-ui-backend/pom.xml b/vospace-ui-backend/pom.xml
index 44cdc59..fcbf7be 100644
--- a/vospace-ui-backend/pom.xml
+++ b/vospace-ui-backend/pom.xml
@@ -34,6 +34,11 @@
             <artifactId>auth-lib</artifactId>
             <version>2.0.0-SNAPSHOT</version>
         </dependency>
+        <dependency>
+            <groupId>org.jsoup</groupId>
+            <artifactId>jsoup</artifactId>
+            <version>1.13.1</version>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-devtools</artifactId>
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
index 2490d31..61dc482 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
@@ -3,7 +3,7 @@ package it.inaf.ia2.vospace.ui.controller;
 import it.inaf.ia2.aa.data.User;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
 import it.inaf.ia2.vospace.ui.data.ListNodeData;
-import it.inaf.ia2.vospace.ui.service.NodesService;
+import it.inaf.ia2.vospace.ui.service.NodesHtmlGenerator;
 import it.inaf.oats.vospace.datamodel.NodeUtils;
 import java.util.List;
 import java.util.Map;
@@ -12,6 +12,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
 import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.Node;
 import net.ivoa.xml.vospace.v2.Property;
 import net.ivoa.xml.vospace.v2.Protocol;
 import net.ivoa.xml.vospace.v2.Transfer;
@@ -32,13 +33,10 @@ import org.springframework.web.bind.annotation.RestController;
 public class NodesController extends BaseController {
 
     private static final Logger LOG = LoggerFactory.getLogger(NodesController.class);
-    
+
     @Value("${vospace-authority}")
     private String authority;
 
-    @Autowired
-    private NodesService nodesService;
-
     @Autowired
     private VOSpaceClient client;
 
@@ -51,7 +49,16 @@ public class NodesController extends BaseController {
         String path = getPath("/nodes/");
         LOG.debug("listNodes called for path {}", path);
 
-        return ResponseEntity.ok(nodesService.generateNodesHtml(path, principal));
+        ListNodeData listNodeData = new ListNodeData();
+
+        Node node = client.getNode(path);
+
+        listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups()));
+
+        NodesHtmlGenerator htmlGenerator = new NodesHtmlGenerator(node, principal, authority);
+        listNodeData.setHtmlTable(htmlGenerator.generateNodes());
+
+        return ResponseEntity.ok(listNodeData);
     }
 
     @GetMapping(value = "/download/**")
@@ -82,7 +89,7 @@ public class NodesController extends BaseController {
             parentPath = "/" + parentPath;
         }
         String name = getRequiredParam(params, "name");
-        
+
         LOG.debug("newFolder called for path {}/{}", parentPath, name);
 
         ContainerNode node = new ContainerNode();
@@ -121,7 +128,7 @@ public class NodesController extends BaseController {
         // All the nodes have been correctly deleted
         return ResponseEntity.noContent().build();
     }
-    
+
     protected String getPath(String prefix) {
         String requestURL = servletRequest.getRequestURL().toString();
         return NodeUtils.getPathFromRequestURLString(requestURL, prefix);
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
index 77e8614..6025e00 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java
@@ -1,7 +1,9 @@
 package it.inaf.ia2.vospace.ui.service;
 
+import it.inaf.ia2.aa.data.User;
 import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
 import it.inaf.oats.vospace.datamodel.NodeProperties;
+import it.inaf.oats.vospace.datamodel.NodeUtils;
 import java.util.List;
 import java.util.Optional;
 import net.ivoa.xml.vospace.v2.DataNode;
@@ -29,8 +31,10 @@ public class NodeInfo {
     private final boolean sticky;
     private final boolean busy;
     private final boolean listOfFiles;
+    private final boolean writable;
+    private final boolean deletable;
 
-    public NodeInfo(Node node, String authority) {
+    public NodeInfo(Node node, User user, String authority) {
         this.authority = authority;
         this.path = getPath(node);
         this.name = path.substring(path.lastIndexOf("/") + 1);
@@ -44,6 +48,8 @@ public class NodeInfo {
         this.sticky = isSticky(node);
         this.busy = isBusy(node);
         this.listOfFiles = isListOfFiles(node);
+        this.writable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups());
+        this.deletable = writable && !sticky;
     }
 
     private String getPath(Node node) {
@@ -58,7 +64,7 @@ public class NodeInfo {
 
         return uri.substring(prefix.length());
     }
-    
+
     private String getCreator(Node node) {
         return getProperty(node, NodeProperties.CREATOR_URI).orElse("");
     }
@@ -82,7 +88,7 @@ public class NodeInfo {
     private boolean isSticky(Node node) {
         return getProperty(node, NodeProperties.STICKY_URN).map(value -> "true".equals(value)).orElse(false);
     }
-    
+
     private boolean isBusy(Node node) {
         return node instanceof DataNode && ((DataNode) node).isBusy();
     }
@@ -189,4 +195,12 @@ public class NodeInfo {
     public boolean isListOfFiles() {
         return listOfFiles;
     }
+
+    public boolean isWritable() {
+        return writable;
+    }
+
+    public boolean isDeletable() {
+        return deletable;
+    }
 }
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java
new file mode 100644
index 0000000..e45c778
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java
@@ -0,0 +1,223 @@
+package it.inaf.ia2.vospace.ui.service;
+
+import it.inaf.ia2.aa.data.User;
+import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.Node;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+
+public class NodesHtmlGenerator {
+
+    private final Node parentNode;
+    private final User user;
+    private final String authority;
+
+    private Element tbody;
+
+    public NodesHtmlGenerator(Node node, User user, String authority) {
+        this.parentNode = node;
+        this.user = user;
+        this.authority = authority;
+    }
+
+    public String generateNodes() {
+
+        Document html = Jsoup.parse("<html></html>");
+
+        tbody = html.body().appendElement("tbody");
+        tbody.attr("id", "nodes");
+
+        if (parentNode instanceof ContainerNode) {
+            ContainerNode folder = (ContainerNode) parentNode;
+            for (Node child : folder.getNodes()) {
+                addChild(child);
+            }
+        }
+
+        return tbody.toString();
+    }
+
+    private void addChild(Node child) {
+        NodeInfo nodeInfo = new NodeInfo(child, user, authority);
+
+        if (nodeInfo.isListOfFiles()) {
+            // hidden file
+            return;
+        }
+
+        Element row = tbody.appendElement("tr");
+
+        addSelectionCell(nodeInfo, row);
+        addLinkCell(nodeInfo, row);
+        addSizeCell(nodeInfo, row);
+        addGroupReadCell(nodeInfo, row);
+        addGroupWriteCell(nodeInfo, row);
+        addActionsCell(nodeInfo, row);
+    }
+
+    private void addSelectionCell(NodeInfo nodeInfo, Element row) {
+
+        Element cell = row.appendElement("td");
+
+        Element input = cell.appendElement("input");
+        input.attr("type", "checkbox");
+        input.attr("data-node", nodeInfo.getPath());
+
+        if (nodeInfo.isAsyncTrans()) {
+            input.addClass("async");
+        } else if (nodeInfo.isDeletable()) {
+            input.addClass("deletable");
+        }
+    }
+
+    private void addLinkCell(NodeInfo nodeInfo, Element row) {
+
+        Element cell = row.appendElement("td");
+        addNodeIcon(nodeInfo, cell);
+        addLink(nodeInfo, cell);
+    }
+
+    private void addSizeCell(NodeInfo nodeInfo, Element row) {
+        Element cell = row.appendElement("td");
+        cell.text(nodeInfo.getSize());
+    }
+
+    private void addGroupReadCell(NodeInfo nodeInfo, Element row) {
+        Element cell = row.appendElement("td");
+        fillGroupCell(cell, nodeInfo.getGroupRead());
+    }
+
+    private void addGroupWriteCell(NodeInfo nodeInfo, Element row) {
+        Element cell = row.appendElement("td");
+        fillGroupCell(cell, nodeInfo.getGroupWrite());
+    }
+
+    private void fillGroupCell(Element cell, String groups) {
+        String[] values = groups.split(" ");
+        List<String> personGroups = new ArrayList<>();
+        List<String> peopleGroups = new ArrayList<>();
+        for (String value : values) {
+            if (value.startsWith("people.")) {
+                personGroups.add(value.substring("people.".length()).replace("\\.", "."));
+            } else {
+                peopleGroups.add(value);
+            }
+        }
+        if (!personGroups.isEmpty()) {
+            Element personIcon = cell.appendElement("span");
+            personIcon.attr("class", "icon person-icon");
+            cell.appendText(String.join(" ", personGroups));
+            cell.append("&nbsp;");
+        }
+        if (!peopleGroups.isEmpty()) {
+            Element personIcon = cell.appendElement("span");
+            personIcon.attr("class", "icon people-icon");
+            cell.appendText(String.join(" ", peopleGroups));
+        }
+    }
+
+    private void addActionsCell(NodeInfo nodeInfo, Element row) {
+        Element cell = row.appendElement("td");
+        if (nodeInfo.isWritable()) {
+            Element shareIcon = cell.appendElement("span");
+            shareIcon.attr("class", "icon share-icon pointer");
+            shareIcon.attr("onclick", "shareNode(" + makeJsArg(nodeInfo.getPath())
+                    + "," + makeJsArg(nodeInfo.getGroupRead())
+                    + "," + makeJsArg(nodeInfo.getGroupWrite()) + ")");
+        }
+        if (nodeInfo.isDeletable()) {
+            cell.append("&nbsp;");
+            Element deleteIcon = cell.appendElement("span");
+            deleteIcon.attr("class", "icon trash-icon pointer");
+            deleteIcon.attr("onclick", "deleteNode(" + makeJsArg(nodeInfo.getPath()) + ")");
+        }
+    }
+
+    private String makeJsArg(String arg) {
+        return "'" + arg.replace("\\", "\\\\").replace("'", "\\'") + "'";
+    }
+
+    private void addNodeIcon(NodeInfo nodeInfo, Element cell) {
+
+        Element iconContainer = cell;
+
+        if (nodeInfo.isBusy()) {
+            Element loadingWrapper = cell.appendElement("span");
+            iconContainer = loadingWrapper;
+            loadingWrapper.addClass("node-busy");
+            Element spinner = loadingWrapper.appendElement("span");
+            spinner.attr("role", "status");
+            spinner.addClass("spinner-border");
+            Element srEl = spinner.appendElement("span");
+            srEl.addClass("sr-only");
+            srEl.text("Loading...");
+        }
+
+        Element icon = cell.appendElement("span");
+        icon.addClass("icon");
+
+        if (nodeInfo.isFolder()) {
+            if (nodeInfo.isAsyncTrans()) {
+                icon.addClass("folder-x-icon");
+            } else {
+                icon.addClass("folder-icon");
+            }
+        } else {
+            if (nodeInfo.isAsyncTrans()) {
+                icon.addClass("file-x-icon");
+            } else {
+                icon.addClass("file-icon");
+            }
+        }
+
+        iconContainer.append("&nbsp;");
+    }
+
+    private void addLink(NodeInfo nodeInfo, Element cell) {
+        if (isDownloadable(nodeInfo, user)) {
+            Element link = cell.appendElement("a");
+            String href;
+            if (nodeInfo.isFolder()) {
+                href = "#/nodes" + urlEncodePath(nodeInfo.getPath());
+            } else {
+                href = "download" + urlEncodePath(nodeInfo.getPath());
+                link.attr("target", "blank_");
+            }
+            link.attr("href", href);
+            link.text(nodeInfo.getName());
+        } else {
+            cell.text(nodeInfo.getName());
+        }
+    }
+
+    private boolean isDownloadable(NodeInfo nodeInfo, User user) {
+        if (nodeInfo.isFile()) {
+            if (nodeInfo.isAsyncTrans() || nodeInfo.isBusy()) {
+                return false;
+            }
+        }
+        if (nodeInfo.isPublic()) {
+            return true;
+        }
+
+        if (nodeInfo.getCreator().equals(user.getName())) {
+            return true;
+        }
+
+        if (user.getGroups() != null && !user.getGroups().isEmpty() && !nodeInfo.getGroupRead().isEmpty()) {
+            List<String> groupRead = Arrays.asList(nodeInfo.getGroupRead().split(" "));
+            for (String group : groupRead) {
+                if (user.getGroups().contains(group)) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
deleted file mode 100644
index 0934c8d..0000000
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package it.inaf.ia2.vospace.ui.service;
-
-import it.inaf.ia2.aa.data.User;
-import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
-import it.inaf.ia2.vospace.ui.data.ListNodeData;
-import it.inaf.oats.vospace.datamodel.NodeUtils;
-import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.io.UncheckedIOException;
-import java.util.Arrays;
-import java.util.List;
-import net.ivoa.xml.vospace.v2.ContainerNode;
-import net.ivoa.xml.vospace.v2.Node;
-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.stereotype.Service;
-
-@Service
-public class NodesService {
-
-    private static final Logger LOG = LoggerFactory.getLogger(NodesService.class);
-
-    @Autowired
-    private VOSpaceClient client;
-
-    @Value("${vospace-authority}")
-    private String authority;
-
-    public ListNodeData generateNodesHtml(String path, User user) {
-
-        ListNodeData listNodeData = new ListNodeData();
-
-        Node node = client.getNode(path);
-
-        listNodeData.setWritable(NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()));
-
-        try (StringWriter sw = new StringWriter()) {
-
-            if (node instanceof ContainerNode) {
-                ContainerNode folder = (ContainerNode) node;
-                sw.write("<tbody id=\"nodes\">");
-                for (Node child : folder.getNodes()) {
-                    sw.write(getNodeHtml(child, user));
-                }
-                sw.write("</tbody>");
-            }
-
-            listNodeData.setHtmlTable(sw.toString());
-
-            return listNodeData;
-        } catch (IOException ex) {
-            throw new UncheckedIOException(ex);
-        }
-    }
-
-    private String getNodeHtml(Node node, User user) {
-
-        NodeInfo nodeInfo = new NodeInfo(node, authority);
-
-        if (nodeInfo.isListOfFiles()) {
-            // hidden file
-            return "";
-        }
-
-        boolean deletable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !nodeInfo.isSticky();
-
-        String html = "<tr>";
-        html += "<td><input type=\"checkbox\" data-node=\"" + nodeInfo.getPath().replace("\"", "\\\"") + "\" ";
-        if (nodeInfo.isAsyncTrans()) {
-            html += "class=\"async\"";
-        } else if (deletable) {
-            html += "class=\"deletable\"";
-        }
-        html += "/></td>";
-        html += "<td>" + getIcon(nodeInfo) + getLink(nodeInfo, user) + "</td>";
-        html += "<td>" + nodeInfo.getSize() + "</td>";
-        html += "<td>" + nodeInfo.getGroupRead() + "</td>";
-        html += "<td>" + nodeInfo.getGroupWrite() + "</td>";
-        html += "<td>";
-        if (deletable) {
-            html += "<span class=\"icon trash-icon pointer\" onclick=\"deleteNode('" + nodeInfo.getPath() + "')\"></span>";
-        }
-        html += "</td>";
-        html += "</tr>";
-        return html;
-    }
-
-    private String getIcon(NodeInfo nodeInfo) {
-        String html = "";
-        if (nodeInfo.isBusy()) {
-            html += "<span class=\"node-busy\"><span role=\"status\" class=\"spinner-border\"><span class=\"sr-only\">Loading...</span></span>";
-        }
-        html += "<span class=\"icon ";
-        if (nodeInfo.isFolder()) {
-            html += "folder";
-        } else {
-            html += "file";
-        }
-        if (nodeInfo.isAsyncTrans()) {
-            html += "-x";
-        }
-        html += "-icon\"></span>";
-        if (nodeInfo.isBusy()) {
-            html += "</span>";
-        }
-        html += "&nbsp;";
-        return html;
-    }
-
-    private String getLink(NodeInfo nodeInfo, User user) {
-        if (isDownloadable(nodeInfo, user)) {
-            if (nodeInfo.isFolder()) {
-                return "<a href=\"#/nodes" + urlEncodePath(nodeInfo.getPath()) + "\">" + nodeInfo.getName() + "</a>";
-            } else {
-                return "<a href=\"download" + urlEncodePath(nodeInfo.getPath()) + "\" target=\"blank_\">" + nodeInfo.getName() + "</a>";
-            }
-        }
-        return nodeInfo.getName();
-    }
-
-    private boolean isDownloadable(NodeInfo nodeInfo, User user) {
-        if (nodeInfo.isFile()) {
-            if (nodeInfo.isAsyncTrans() || nodeInfo.isBusy()) {
-                return false;
-            }
-        }
-        if (nodeInfo.isPublic()) {
-            return true;
-        }
-
-        if (nodeInfo.getCreator().equals(user.getName())) {
-            return true;
-        }
-
-        if (user.getGroups() != null && !user.getGroups().isEmpty() && !nodeInfo.getGroupRead().isEmpty()) {
-            List<String> groupRead = Arrays.asList(nodeInfo.getGroupRead().split(" "));
-            for (String group : groupRead) {
-                if (user.getGroups().contains(group)) {
-                    return true;
-                }
-            }
-        }
-
-        return false;
-    }
-}
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
index 39e3863..110a932 100644
--- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
@@ -2,17 +2,17 @@ package it.inaf.ia2.vospace.ui.controller;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
-import it.inaf.ia2.vospace.ui.service.NodesService;
 import java.util.Arrays;
 import java.util.List;
+import net.ivoa.xml.vospace.v2.DataNode;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import org.junit.jupiter.api.Test;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
-import org.mockito.Mockito;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
 import org.springframework.boot.test.context.SpringBootTest;
@@ -30,9 +30,6 @@ public class NodesControllerTest {
 
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
-    @MockBean
-    private NodesService nodesService;
-
     @MockBean
     private VOSpaceClient client;
 
@@ -42,28 +39,34 @@ public class NodesControllerTest {
     @Test
     public void testListNodesEmpty() throws Exception {
 
+        when(client.getNode(any())).thenReturn(new DataNode());
+
         mockMvc.perform(get("/nodes"))
                 .andExpect(status().isOk());
 
-        verify(nodesService).generateNodesHtml(eq("/"), any());
+        verify(client, times(1)).getNode(eq("/"));
     }
 
     @Test
     public void testListNodesRoot() throws Exception {
 
+        when(client.getNode(any())).thenReturn(new DataNode());
+
         mockMvc.perform(get("/nodes/"))
                 .andExpect(status().isOk());
 
-        verify(nodesService).generateNodesHtml(eq("/"), any());
+        verify(client, times(1)).getNode(eq("/"));
     }
 
     @Test
     public void testListNodesComplexPath() throws Exception {
 
+        when(client.getNode(any())).thenReturn(new DataNode());
+
         mockMvc.perform(get("/nodes/a/b/c"))
                 .andExpect(status().isOk());
 
-        verify(nodesService).generateNodesHtml(eq("/a/b/c"), any());
+        verify(client, times(1)).getNode(eq("/a/b/c"));
     }
 
     @Test
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java
index f113953..102bdc9 100644
--- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java
@@ -1,5 +1,6 @@
 package it.inaf.ia2.vospace.ui.service;
 
+import it.inaf.ia2.aa.data.User;
 import net.ivoa.xml.vospace.v2.DataNode;
 import net.ivoa.xml.vospace.v2.Node;
 import net.ivoa.xml.vospace.v2.Property;
@@ -48,7 +49,7 @@ public class NodeInfoTest {
     private void testNodeLength(long bytes, String expectedText) {
         DataNode node = getDataNode();
         setLength(node, bytes);
-        NodeInfo nodeInfo = new NodeInfo(node, AUTHORITY);
+        NodeInfo nodeInfo = new NodeInfo(node, new User(), AUTHORITY);
         assertEquals(expectedText, nodeInfo.getSize());
     }
 
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java
new file mode 100644
index 0000000..a462ac7
--- /dev/null
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java
@@ -0,0 +1,53 @@
+package it.inaf.ia2.vospace.ui.service;
+
+import it.inaf.ia2.aa.data.User;
+import it.inaf.oats.vospace.datamodel.NodeProperties;
+import java.util.Arrays;
+import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.DataNode;
+import net.ivoa.xml.vospace.v2.Node;
+import net.ivoa.xml.vospace.v2.Property;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.Test;
+
+public class NodesHtmlGeneratorTest {
+
+    @Test
+    public void testGenerateNodes() {
+
+        ContainerNode parent = new ContainerNode();
+        parent.setUri("vos://example.com!vospace/mynode");
+        setGroups(parent, "group1", "group1");
+
+        DataNode file1 = new DataNode();
+        file1.setUri("vos://example.com!vospace/mynode/file1");
+        setGroups(file1, "group1", "group2 people.name\\.surname");
+        parent.getNodes().add(file1);
+
+        User user = new User();
+        user.setUserId("user_id");
+        user.setGroups(Arrays.asList("group1", "group2"));
+
+        NodesHtmlGenerator generator = new NodesHtmlGenerator(parent, user, "example.com!vospace");
+
+        String html = generator.generateNodes();
+
+        System.out.println(html);
+
+        assertTrue(html.startsWith("<tbody id=\"nodes\">"));
+        assertTrue(html.contains("<input type=\"checkbox\" data-node=\"/mynode/file1\" class=\"deletable\">"));
+    }
+
+    private void setGroups(Node node, String groupRead, String groupWrite) {
+        Property groupReadProperty = new Property();
+        groupReadProperty.setUri(NodeProperties.GROUP_READ_URI);
+        groupReadProperty.setValue(groupRead);
+
+        Property groupWriteProperty = new Property();
+        groupWriteProperty.setUri(NodeProperties.GROUP_WRITE_URI);
+        groupWriteProperty.setValue(groupWrite);
+
+        node.getProperties().add(groupReadProperty);
+        node.getProperties().add(groupWriteProperty);
+    }
+}
-- 
GitLab