diff --git a/src/main/java/it/inaf/oats/vospace/AbstractNodeService.java b/src/main/java/it/inaf/oats/vospace/AbstractNodeService.java
new file mode 100644
index 0000000000000000000000000000000000000000..2847bf4e66b06f3c9177aeb5d8dbd3ad71f824fa
--- /dev/null
+++ b/src/main/java/it/inaf/oats/vospace/AbstractNodeService.java
@@ -0,0 +1,46 @@
+/*
+ * This file is part of vospace-rest
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.inaf.oats.vospace;
+
+import it.inaf.oats.vospace.exception.InternalFaultException;
+import it.inaf.oats.vospace.exception.NodeBusyException;
+import it.inaf.oats.vospace.exception.PermissionDeniedException;
+import it.inaf.oats.vospace.persistence.NodeDAO;
+import it.inaf.oats.vospace.persistence.NodeDAO.ShortNodeDescriptor;
+import javax.servlet.http.HttpServletRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+
+public abstract class AbstractNodeService {
+    
+    @Autowired
+    protected NodeDAO nodeDao;
+
+    @Value("${vospace-authority}")
+    protected String authority;
+
+    protected void validatePath(String path) {
+        if (path.equals("/")) {
+            throw new IllegalArgumentException("Cannot move root node or to root node");
+        }
+    }
+
+    protected void validateDestinationContainer(ShortNodeDescriptor snd, String destinationVosPath) {
+        if (snd.isBusy()) {
+            throw new NodeBusyException(destinationVosPath);
+        }
+        if (snd.isPermissionDenied()) {
+            throw new PermissionDeniedException(destinationVosPath);
+        }
+        if (!snd.isWritable()) {
+            throw new InternalFaultException("Destination is not writable: " + destinationVosPath);
+        }
+        if (!snd.isContainer()) {
+            throw new InternalFaultException("Existing destination is not a container: " + destinationVosPath);
+        }
+
+    }
+}
diff --git a/src/main/java/it/inaf/oats/vospace/CopyService.java b/src/main/java/it/inaf/oats/vospace/CopyService.java
new file mode 100644
index 0000000000000000000000000000000000000000..5383fa3c3d5d0bd45efdb80079842eca202b21fe
--- /dev/null
+++ b/src/main/java/it/inaf/oats/vospace/CopyService.java
@@ -0,0 +1,117 @@
+/*
+ * This file is part of vospace-rest
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.inaf.oats.vospace;
+
+import it.inaf.ia2.aa.data.User;
+import it.inaf.oats.vospace.datamodel.NodeUtils;
+import it.inaf.oats.vospace.exception.NodeBusyException;
+import it.inaf.oats.vospace.exception.NodeNotFoundException;
+import it.inaf.oats.vospace.exception.PermissionDeniedException;
+import it.inaf.oats.vospace.persistence.NodeDAO.ShortNodeDescriptor;
+import java.util.List;
+import java.util.Optional;
+import net.ivoa.xml.vospace.v2.Transfer;
+import org.springframework.dao.CannotSerializeTransactionException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@EnableTransactionManagement
+public class CopyService extends AbstractNodeService {
+
+
+    @Transactional(rollbackFor = {Exception.class}, isolation = Isolation.REPEATABLE_READ)
+    public List<String> processCopyNodes(Transfer transfer, String jobId, User user) {
+
+        // Get Source Vos Path
+        String sourcePath = URIUtils.returnVosPathFromNodeURI(transfer.getTarget(), authority);
+
+        // Get Destination Vos Path (it's in transfer direction)
+        String destinationPath = URIUtils.returnVosPathFromNodeURI(transfer.getDirection(), authority);
+
+        // Destination source to be returned, null if no copy was performed
+        String destinationCopyRoot = null;
+
+        this.validatePath(sourcePath);
+        this.validatePath(destinationPath);
+
+        if (sourcePath.equals(destinationPath)) {
+            throw new IllegalArgumentException("Cannot copy node to itself");
+        }
+
+        // Check if destination is subpath of source
+        // Linux-like: "cannot copy to a subdirectory of itself" 
+        if (destinationPath.startsWith(sourcePath + "/")) {
+            throw new IllegalArgumentException("Cannot copy node to a subdirectory of its own path");
+        }
+
+        // Check if destination equals parent path of source
+        if (NodeUtils.getParentPath(sourcePath).equals(destinationPath)) {
+            throw new IllegalArgumentException("Cannot duplicate node at same path without renaming it");
+        }
+
+        try {
+
+            // check source branch for read and lock it
+            this.checkBranchForReadAndLock(sourcePath, jobId, user);
+
+            // Check destination
+            Optional<ShortNodeDescriptor> destShortNodeDescriptor
+                    = nodeDao.getShortNodeDescriptor(destinationPath, user.getName(), user.getGroups());
+
+            if (destShortNodeDescriptor.isPresent()) {
+                this.validateDestinationContainer(destShortNodeDescriptor.get(), destinationPath);
+                destinationCopyRoot = destinationPath + "/" + NodeUtils.getNodeName(sourcePath);
+
+            } else {
+                // Check if parent exists
+                String destinationParentPath = NodeUtils.getParentPath(destinationPath);
+                Optional<ShortNodeDescriptor> destShortNodeDescriptorParent
+                        = nodeDao.getShortNodeDescriptor(destinationParentPath, user.getName(), user.getGroups());
+                if (destShortNodeDescriptorParent.isPresent()) {
+                    this.validateDestinationContainer(destShortNodeDescriptorParent.get(), destinationParentPath);
+                    destinationCopyRoot = destinationPath;
+
+                } else {
+                    throw new UnsupportedOperationException("Creation of destination upon copy not supported");
+                }
+
+            }
+
+            nodeDao.copyBranch(
+                    sourcePath,
+                    destinationCopyRoot);                        
+
+        } catch (CannotSerializeTransactionException ex) {
+            // Concurrent transactions attempted to modify this set of nodes            
+            throw new NodeBusyException(sourcePath);
+        }
+        
+        return List.of(sourcePath, destinationCopyRoot);
+        
+    }
+
+    private void checkBranchForReadAndLock(String sourcePath, String jobId, User user) {
+
+        // Get source node
+        Optional<Long> sourceIdOpt = nodeDao.getNodeId(sourcePath);
+        long sourceId = sourceIdOpt.orElseThrow(() -> new NodeNotFoundException(sourcePath));
+
+        if (nodeDao.isBranchBusy(sourceId)) {
+            throw new NodeBusyException(sourcePath);
+        }
+
+        if (!nodeDao.isBranchReadable(sourceId, user.getName(), user.getGroups())) {
+            throw new PermissionDeniedException(sourcePath);
+        }
+
+        nodeDao.setBranchJobId(sourceId, jobId);
+
+    }
+
+}
diff --git a/src/main/java/it/inaf/oats/vospace/FileServiceClient.java b/src/main/java/it/inaf/oats/vospace/FileServiceClient.java
index 83a8f1b3a888406d158334adf6b944f18b6b9835..f23a4b293c6496f8802ae2c017af658fa00f3fbe 100644
--- a/src/main/java/it/inaf/oats/vospace/FileServiceClient.java
+++ b/src/main/java/it/inaf/oats/vospace/FileServiceClient.java
@@ -79,13 +79,72 @@ public class FileServiceClient {
                 headers.setBearerAuth(token);
             }
             headers.setContentType(MediaType.APPLICATION_JSON);
-            try ( OutputStream os = req.getBody()) {
+            try (OutputStream os = req.getBody()) {
                 MAPPER.writeValue(os, archiveRequest);
             }
         }, res -> {
             return res.getHeaders().getLocation().toString();
         }, new Object[]{});
     }
+    
+    public void startFileCopyJob(String sourceVosPath, 
+            String destiantionVosPath, String jobId, User user) {
+        
+        CopyRequest copyRequest = new CopyRequest();
+        copyRequest.setJobId(jobId);
+        copyRequest.setSourceRootVosPath(sourceVosPath);
+        copyRequest.setDestinationRootVosPath(destiantionVosPath);
+        
+        String url = fileServiceUrl + "/copy";
+        
+        String token = user.getAccessToken();
+        restTemplate.execute(url, HttpMethod.POST, req -> {
+            HttpHeaders headers = req.getHeaders();
+            if (token != null) {
+                headers.setBearerAuth(token);
+            }
+
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            try (OutputStream os = req.getBody()) {
+                MAPPER.writeValue(os, copyRequest);
+            }
+        }, res -> {           
+           return null;
+        }, new Object[]{});
+        
+    }
+
+    public static class CopyRequest {
+
+        private String jobId;
+        private String sourceRootVosPath;
+        private String destinationRootVosPath;
+
+        public String getJobId() {
+            return jobId;
+        }
+
+        public void setJobId(String jobId) {
+            this.jobId = jobId;
+        }
+
+        public String getSourceRootVosPath() {
+            return sourceRootVosPath;
+        }
+
+        public void setSourceRootVosPath(String sourceRootVosPath) {
+            this.sourceRootVosPath = sourceRootVosPath;
+        }
+
+        public String getDestinationRootVosPath() {
+            return destinationRootVosPath;
+        }
+
+        public void setDestinationRootVosPath(String destinationRootVosPath) {
+            this.destinationRootVosPath = destinationRootVosPath;
+        }
+
+    }
 
     public static class ArchiveRequest {
 
diff --git a/src/main/java/it/inaf/oats/vospace/JobService.java b/src/main/java/it/inaf/oats/vospace/JobService.java
index 5a8c511604f821a25fb3543d50ee98ed556486b2..938fbee1462b45ddde14206af8663321d47eea69 100644
--- a/src/main/java/it/inaf/oats/vospace/JobService.java
+++ b/src/main/java/it/inaf/oats/vospace/JobService.java
@@ -18,6 +18,8 @@ import it.inaf.oats.vospace.exception.InvalidArgumentException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import it.inaf.oats.vospace.exception.VoSpaceErrorSummarizableException;
+import it.inaf.oats.vospace.persistence.NodeDAO;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
@@ -40,11 +42,20 @@ public class JobService {
     @Autowired
     private MoveService moveService;
 
+    @Autowired
+    private CopyService copyService;
+    
     @Autowired
     private AsyncTransferService asyncTransfService;
 
     @Autowired
     private HttpServletRequest servletRequest;
+    
+    @Autowired
+    private FileServiceClient fileServiceClient;
+    
+    @Autowired
+    private NodeDAO nodeDao;
 
     public enum JobDirection {
         pullToVoSpace,
@@ -119,6 +130,9 @@ public class JobService {
                 case moveNode:
                     handleMoveNode(job, transfer);
                     break;
+                case copyNode:
+                    handleCopyNode(job, transfer);
+                    break;
                 default:
                     throw new UnsupportedOperationException("Not implemented yet");
             }
@@ -177,8 +191,33 @@ public class JobService {
         });
     }
 
+    private void handleCopyNode(JobSummary jobSummary, Transfer transfer) {
+        // User data must be extracted before starting the new thread
+        // to avoid the "No thread-bound request found" exception
+        User user = (User) servletRequest.getUserPrincipal();
+        CompletableFuture.runAsync(() -> {
+            handleJobErrors(jobSummary, job -> {
+                
+                String jobId = jobSummary.getJobId();
+                // Index 0: source 1: destination
+                List<String> sourceAndDestination = copyService.processCopyNodes(transfer, jobId, user);
+                // Call file service and command copy
+                try{                
+                fileServiceClient.startFileCopyJob(sourceAndDestination.get(0), sourceAndDestination.get(1), jobId, user);
+                } catch (Exception e) {
+                    // We decided not to purge metadata in case of failure
+                    // just release busy nodes setting job_id = null
+                    nodeDao.releaseBusyNodesByJobId(jobId);                    
+                    throw e;
+                }
+                
+                return null;
+            });
+        });
+    }
+
     private void handleJobErrors(JobSummary job, Function<JobSummary, Transfer> jobConsumer) {
-        Transfer negotiatedTransfer = null;
+        Transfer negotiatedTransfer = null;        
         try {
             negotiatedTransfer = jobConsumer.apply(job);
         } catch (VoSpaceErrorSummarizableException e) {
diff --git a/src/main/java/it/inaf/oats/vospace/MoveService.java b/src/main/java/it/inaf/oats/vospace/MoveService.java
index 8968386ad2d9702c2414e93b8ca4be1034829c87..6210c24001f80c3236fd84f3dff75cf28c016b9f 100644
--- a/src/main/java/it/inaf/oats/vospace/MoveService.java
+++ b/src/main/java/it/inaf/oats/vospace/MoveService.java
@@ -11,12 +11,9 @@ import it.inaf.oats.vospace.exception.InternalFaultException;
 import it.inaf.oats.vospace.exception.NodeBusyException;
 import it.inaf.oats.vospace.exception.NodeNotFoundException;
 import it.inaf.oats.vospace.exception.PermissionDeniedException;
-import it.inaf.oats.vospace.persistence.NodeDAO;
 import it.inaf.oats.vospace.persistence.NodeDAO.ShortNodeDescriptor;
 import java.util.Optional;
 import net.ivoa.xml.vospace.v2.Transfer;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.dao.CannotSerializeTransactionException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -25,13 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 @Service
 @EnableTransactionManagement
-public class MoveService {
-
-    @Autowired
-    private NodeDAO nodeDao;
-
-    @Value("${vospace-authority}")
-    private String authority;
+public class MoveService extends AbstractNodeService {
     
     /**
      * Perform modeNode operation. User is passed as parameter because this method
@@ -52,13 +43,18 @@ public class MoveService {
         this.validatePath(destinationPath);
 
         if (sourcePath.equals(destinationPath)) {
-            return;
+            throw new IllegalArgumentException("Cannot move node to itself");
         }
         
         // Check if destination is subpath of source
         // Linux-like: "cannot move to a subdirectory of itself" 
         if(destinationPath.startsWith(sourcePath+"/")) {
             throw new IllegalArgumentException("Cannot move node to a subdirectory of its own path");
+        }
+
+        // Check if destination equals parent path of source
+        if(NodeUtils.getParentPath(sourcePath).equals(destinationPath)){
+            return;
         }        
 
         try {
@@ -110,13 +106,6 @@ public class MoveService {
             // Concurrent transactions attempted to modify this set of nodes            
             throw new NodeBusyException(sourcePath);
         }
-    }
-
-    
-    private void validatePath(String path) {        
-        if (path.equals("/")) {            
-            throw new IllegalArgumentException("Cannot move root node or to root node");
-        }
     }  
 
 }
diff --git a/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java b/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java
index 228532b006ba787dbac9565a5e58a8bf2a4f65f0..a3f144407735684dfbcf3fc47dcf815d6c25be06 100644
--- a/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java
+++ b/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java
@@ -135,6 +135,25 @@ public class NodeDAO {
         return Optional.of(node);
     }
 
+    public List<String> listNodeChildren(String path) {
+
+        String sql = "SELECT n.name\n"
+                + "FROM node n\n"
+                + "WHERE n.path ~ ('*.' || id_from_vos_path(?) || '.*{1}')::lquery\n"
+                + "ORDER BY n.path";
+
+        List<String> childrenNames = jdbcTemplate.query(conn -> {
+            PreparedStatement ps = conn.prepareStatement(sql);
+            int i = 0;
+            ps.setString(++i, path);
+            return ps;
+        }, (row, index) -> {
+            return row.getString("name");
+        });
+
+        return childrenNames;
+    }
+
     public Node setNode(Node newNode) {
         return setNode(newNode, false);
     }
@@ -309,6 +328,61 @@ public class NodeDAO {
         });
     }
 
+    public void copyBranch(String sourceVosPath, String destVosPath) {
+        
+        String destVosParentPath = NodeUtils.getParentPath(destVosPath);
+        String destName = NodeUtils.getNodeName(destVosPath);
+
+        String parentInsert = "INSERT INTO node (node_id, parent_path, parent_relative_path, name, type, location_id, creator_id, group_write, group_read, is_public,\n"
+                + "job_id, tstamp_wrapper_dir, format, async_trans, sticky, accept_views, provide_views, protocols)\n";
+
+        String ctePathPrefix = "SELECT CASE WHEN path::varchar = '' THEN '' ELSE (path::varchar || '.') END AS prefix\n"
+                + "FROM node WHERE node_id = id_from_vos_path(?)";
+
+        String cteCopiedNodes = "SELECT nextval('node_node_id_seq') AS new_node_id,\n"
+                + "((SELECT prefix FROM path_prefix) || currval('node_node_id_seq'))::ltree AS new_path,\n"
+                + "path, relative_path, parent_path, parent_relative_path, ? AS name,\n"
+                + "type, location_id, creator_id, group_write, group_read, is_public,\n"
+                + "job_id, tstamp_wrapper_dir, format, async_trans, sticky, accept_views, provide_views, protocols\n"
+                + "FROM node WHERE node_id = id_from_vos_path(?)\n"
+                + "UNION ALL\n"
+                + "SELECT nextval('node_node_id_seq') AS new_node_id,\n"
+                + "(p.new_path::varchar || '.' || currval('node_node_id_seq'))::ltree,\n"
+                + "n.path, n.relative_path, n.parent_path, n.parent_relative_path, n.name,\n"
+                + "n.type, n.location_id, n.creator_id, n.group_write, n.group_read, n.is_public,\n"
+                + "n.job_id, n.tstamp_wrapper_dir, n.format, n.async_trans, n.sticky, n.accept_views, n.provide_views, n.protocols\n"
+                + "FROM node n\n"
+                + "JOIN copied_nodes p ON p.path = n.parent_path";
+
+        String cteCopiedNodesPaths = "SELECT subpath(new_path, 0, nlevel(new_path) - 1) AS new_parent_path,\n"
+                + "nlevel(parent_path) - nlevel(parent_relative_path) AS rel_offset, * FROM copied_nodes";
+
+        String parentSelect = "SELECT\n"
+                + "new_node_id, new_parent_path,\n"
+                + "CASE WHEN nlevel(new_parent_path) = rel_offset THEN ''::ltree ELSE subpath(new_parent_path, rel_offset) END new_parent_relative_path,\n"
+                + "name, type, location_id, creator_id, group_write, group_read, is_public,\n"
+                + "job_id, tstamp_wrapper_dir, format, async_trans, sticky, accept_views, provide_views, protocols\n"
+                + "FROM copied_nodes_paths\n";
+
+        String sql = parentInsert
+                + "WITH RECURSIVE path_prefix AS ("
+                + ctePathPrefix + "),\n"
+                + "copied_nodes AS ("
+                + cteCopiedNodes + "),\n"
+                + "copied_nodes_paths AS ("
+                + cteCopiedNodesPaths + ")\n"
+                + parentSelect;
+             
+        jdbcTemplate.update(conn -> {
+            PreparedStatement ps = conn.prepareStatement(sql);
+            ps.setString(1, destVosParentPath);
+            ps.setString(2, destName);
+            ps.setString(3, sourceVosPath);
+            return ps;
+        });      
+        
+    }
+
     public boolean isBranchBusy(long parentNodeId) {
 
         String sql = "SELECT COUNT(c.node_id) > 0 "
@@ -319,6 +393,28 @@ public class NodeDAO {
         return jdbcTemplate.queryForObject(sql, new Object[]{parentNodeId}, new int[]{Types.BIGINT}, Boolean.class);
     }
 
+    public void setBranchJobId(Long rootNodeId, String jobId) {
+        String sql = "UPDATE node SET job_id = ?\n"
+                + "WHERE path ~ ('*.' || ? || '.*')::lquery";
+
+        jdbcTemplate.update(conn -> {
+            PreparedStatement ps = conn.prepareStatement(sql);
+            ps.setString(1, jobId);
+            ps.setLong(2, rootNodeId);
+            return ps;
+        });
+    }
+    
+    public void releaseBusyNodesByJobId(String jobId) {
+        String sql = "UPDATE node SET job_id = NULL WHERE job_id = ?";
+        
+        jdbcTemplate.update(conn -> {
+            PreparedStatement ps = conn.prepareStatement(sql);
+            ps.setString(1, jobId);           
+            return ps;
+        });        
+    }
+
     public boolean isBranchWritable(long parentNodeId, String userId, List<String> userGroups) {
 
         String sql = "SELECT COUNT(c.node_id) = 0 "
@@ -350,6 +446,36 @@ public class NodeDAO {
         });
     }
 
+    public boolean isBranchReadable(long parentNodeId, String userId, List<String> userGroups) {
+
+        String sql = "SELECT COUNT(c.node_id) = 0 "
+                + "FROM node n "
+                + "JOIN node c ON c.path <@ n.path "
+                + "WHERE n.node_id = ? AND "
+                + "NOT COALESCE(c.is_public, FALSE) "
+                + "AND (SELECT COUNT(*) FROM (SELECT UNNEST(?) INTERSECT SELECT UNNEST(c.group_read)) AS allowed_groups) = 0 "
+                + "AND c.creator_id <> ?";
+
+        return jdbcTemplate.query(sql, ps -> {
+            ps.setLong(1, parentNodeId);
+
+            String[] groups;
+            if (userGroups == null) {
+                groups = new String[0];
+            } else {
+                groups = userGroups.toArray(String[]::new);
+            }
+            ps.setArray(2, ps.getConnection().createArrayOf("varchar", groups));
+
+            ps.setString(3, userId);
+        }, row -> {
+            if (!row.next()) {
+                throw new IllegalStateException("Expected one result");
+            }
+            return row.getBoolean(1);
+        });
+    }
+
     public void deleteNode(String path) {
         int nodesWithPath = countNodesWithPath(path);
         if (nodesWithPath == 0) {
diff --git a/src/test/java/it/inaf/oats/vospace/CopyServiceTest.java b/src/test/java/it/inaf/oats/vospace/CopyServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f28931e41af80789a85583a2da31ee2e73a8bbf5
--- /dev/null
+++ b/src/test/java/it/inaf/oats/vospace/CopyServiceTest.java
@@ -0,0 +1,223 @@
+/*
+ * This file is part of vospace-rest
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.inaf.oats.vospace;
+
+import it.inaf.ia2.aa.data.User;
+import it.inaf.oats.vospace.exception.NodeBusyException;
+import it.inaf.oats.vospace.exception.NodeNotFoundException;
+import it.inaf.oats.vospace.exception.PermissionDeniedException;
+import it.inaf.oats.vospace.persistence.DataSourceConfigSingleton;
+import it.inaf.oats.vospace.persistence.NodeDAO;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import net.ivoa.xml.vospace.v2.Transfer;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.ContextConfiguration;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ContextConfiguration(classes = DataSourceConfigSingleton.class)
+@TestPropertySource(locations = "classpath:test.properties", properties = {"vospace-authority=example.com!vospace", "file-service-url=http://file-service"})
+@TestMethodOrder(OrderAnnotation.class)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+public class CopyServiceTest {
+
+    @Value("${vospace-authority}")
+    private String authority;
+
+    @Autowired
+    private CopyService copyService;
+
+    @Autowired
+    private NodeDAO nodeDao;
+
+    @Test
+    @Order(1)
+    public void copyRootTest() {
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/", "/pippo"), "job_pippo", getAnonymousUser());
+        }
+        );
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/pippo", "/"), "job_pippo", getAnonymousUser());
+        }
+        );
+
+    }
+
+    @Test
+    @Order(2)
+    public void testNonExistingSourceNode() {
+        assertThrows(NodeNotFoundException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/pippo", "/test2"), "job_pippo", getAnonymousUser());
+        }
+        );
+    }
+
+    @Test
+    @Order(3)
+    public void testCopyDeniedOnBusySource() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user3");
+
+        assertThrows(NodeBusyException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/test3/mbusy", "/test3/m1"), "job_pippo", user);
+        }
+        );
+    }
+
+    @Test
+    @Order(4)
+    public void testPermissionDeniedOnSource() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user1");
+
+        assertThrows(PermissionDeniedException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/test3/m1", "/test4"), "job_pippo", user);
+        }
+        );
+    }
+
+    @Test
+    @Order(5)
+    public void testPermissionDeniedOnExistingDestination() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user1");
+        when(user.getGroups()).thenReturn(List.of("group1"));
+
+        assertThrows(PermissionDeniedException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/test3/group1", "/test3/m1/m2"), "job_pippo", user);
+        }
+        );
+    }
+
+    @Test
+    @Order(6)
+    public void testDestinationExistsAndIsBusy() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user3");
+
+        assertThrows(NodeBusyException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/test3/m1", "/test3/mbusy"), "job_pippo", user);
+        }
+        );
+    }
+
+    @Test
+    @Order(7)
+    public void testCopyToExistingDestination() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user3");
+
+        // Preliminary checks for assumptions
+        Optional<Long> sourceId = nodeDao.getNodeId("/test3/m1");
+        assertTrue(sourceId.isPresent());
+        Optional<Long> childId = nodeDao.getNodeId("/test3/m1/m2");
+        assertTrue(childId.isPresent());
+
+        Optional<Long> destId = nodeDao.getNodeId("/test4");
+        assertTrue(destId.isPresent());
+
+        // copy
+        String copyDestination
+                = copyService.processCopyNodes(getTransfer("/test3/m1", "/test4"), "job_pippo", user).get(1);
+
+        assertEquals("/test4/m1", copyDestination);
+
+        // source has been moved
+        Optional<Long> oldSourceId = nodeDao.getNodeId("/test3/m1");
+        assertTrue(oldSourceId.isPresent());
+        Optional<Long> oldChildId = nodeDao.getNodeId("/test3/m1/m2");
+        assertTrue(oldChildId.isPresent());
+
+        Optional<Long> newSourceId = nodeDao.getNodeId("/test4/m1");
+        assertTrue(newSourceId.isPresent());
+
+        Optional<Long> newChildId = nodeDao.getNodeId("/test4/m1/m2");
+        assertTrue(newChildId.isPresent());
+
+    }
+
+    @Test
+    @Order(8)
+    public void testCopyToExistingParent() {
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user3");
+
+        // Preliminary checks for assumptions
+        Optional<Long> sourceId = nodeDao.getNodeId("/test3/m1");
+        assertTrue(sourceId.isPresent());
+        Optional<Long> childId = nodeDao.getNodeId("/test3/m1/m2");
+        assertTrue(childId.isPresent());
+
+        Optional<Long> destId = nodeDao.getNodeId("/test3/m1/m2_copy");
+        assertTrue(destId.isEmpty());
+
+        // copy
+        String copyDestination
+                = copyService.processCopyNodes(getTransfer("/test3/m1/m2", "/test3/m1/m2_copy"), "job_pippo", user).get(1);
+
+        assertEquals("/test3/m1/m2_copy", copyDestination);
+
+        // source has been moved
+        Optional<Long> oldSourceId = nodeDao.getNodeId("/test3/m1");
+        assertTrue(oldSourceId.isPresent());
+        Optional<Long> oldChildId = nodeDao.getNodeId("/test3/m1/m2");
+        assertTrue(oldChildId.isPresent());
+
+        Optional<Long> newSourceId = nodeDao.getNodeId("/test3/m1/m2_copy");
+        assertTrue(newSourceId.isPresent());
+
+    }
+
+    @Test
+    @Order(9)
+    public void testCopyDeniedToExistingDestination() {
+
+        User user = mock(User.class);
+        when(user.getName()).thenReturn("user3");
+
+        // Preliminary checks for assumptions
+        Optional<Long> sourceId = nodeDao.getNodeId("/test3/m1");
+        assertTrue(sourceId.isPresent());
+        Optional<Long> childId = nodeDao.getNodeId("/test3/m1/m2");
+        assertTrue(childId.isPresent());
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            copyService.processCopyNodes(getTransfer("/test3/m1/m2", "/test3/m1"), "job_pippo", user);
+        }
+        );
+    }
+
+    private Transfer getTransfer(String vosTarget, String vosDestination) {
+        Transfer transfer = new Transfer();
+        transfer.setTarget("vos://" + this.authority + vosTarget);
+        transfer.setDirection("vos://" + this.authority + vosDestination);
+        return transfer;
+    }
+
+    private User getAnonymousUser() {
+        return new User().setUserId("anonymous");
+    }
+}
diff --git a/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java b/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java
index b632a65be1d838695e6e979a05ad723b69a9ec77..e5f8aaf7370fe296dce276d4eb2c395b6438e4e6 100644
--- a/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java
+++ b/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java
@@ -187,9 +187,6 @@ public class MoveServiceTest {
         Optional<Long> destParentId = nodeDao.getNodeId("/test4");
         assertTrue(destParentId.isPresent());
 
-        Optional<Long> destId = nodeDao.getNodeId("/test4");
-        assertTrue(destId.isPresent());
-
         // move
         moveService.processMoveJob(getTransfer("/test3/m1", "/test4"), user);
 
diff --git a/src/test/java/it/inaf/oats/vospace/NodeBranchServiceTest.java b/src/test/java/it/inaf/oats/vospace/NodeBranchServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..1a07c39015e129a1d7ac1ce4ff4d521e5475552a
--- /dev/null
+++ b/src/test/java/it/inaf/oats/vospace/NodeBranchServiceTest.java
@@ -0,0 +1,51 @@
+/*
+ * This file is part of vospace-rest
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.inaf.oats.vospace;
+
+import it.inaf.ia2.aa.data.User;
+import it.inaf.oats.vospace.persistence.DataSourceConfigSingleton;
+import it.inaf.oats.vospace.persistence.NodeDAO;
+import net.ivoa.xml.vospace.v2.Transfer;
+import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.ContextConfiguration;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ContextConfiguration(classes = {DataSourceConfigSingleton.class})
+@TestPropertySource(locations = "classpath:test.properties", properties = {"vospace-authority=example.com!vospace", "file-service-url=http://file-service"})
+@TestMethodOrder(OrderAnnotation.class)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+public class NodeBranchServiceTest {
+
+    @Value("${vospace-authority}")
+    private String authority;
+
+    @Autowired
+    private MoveService moveService;
+
+    @Autowired
+    private NodeDAO nodeDao;
+
+    // Stub test class
+    
+    private Transfer getTransfer(String vosTarget, String vosDestination) {
+        Transfer transfer = new Transfer();
+        transfer.setTarget("vos://" + this.authority + vosTarget);
+        transfer.setDirection("vos://" + this.authority + vosDestination);
+        return transfer;
+    }
+
+    private User getAnonymousUser() {
+        return new User().setUserId("anonymous");
+    }
+}
diff --git a/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java b/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java
index 62c064ca1f3c52419ac44e9269c8947d32a07bf5..c85b48a9aa93c7fb9a2503ad9257c93c4b3a5af5 100644
--- a/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java
+++ b/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java
@@ -46,9 +46,9 @@ public class NodeDAOTest {
     @BeforeEach
     public void init() {
         dao = new NodeDAO(dataSource);
-        ReflectionTestUtils.setField(dao, "authority", AUTHORITY);       
+        ReflectionTestUtils.setField(dao, "authority", AUTHORITY);
     }
-
+    
     @Test
     public void testCreateNode() {
         DataNode dataNode = new DataNode();
@@ -81,9 +81,19 @@ public class NodeDAOTest {
         assertEquals(bTime, NodeProperties.getNodePropertyByURI(root.getNodes().get(0), NodeProperties.DATE_URI));
     }
 
+    @Test
+    public void testListNodeChildren() {
+        assertTrue(dao.listNodeChildren("/test4").isEmpty());
+        List<String> children = dao.listNodeChildren("/test2");
+        assertFalse(children.isEmpty());
+        assertTrue(children.size() == 2);
+        assertTrue(children.containsAll(List.of("f4", "f5")));
+        
+    }
+    
     @Test
     public void testGetQuotaAndMD5() {
-
+        
         ContainerNode node = (ContainerNode) dao.listNode("/test1/f1/f2_renamed").get();
         assertEquals("50000", NodeProperties.getNodePropertyByURI(node, NodeProperties.QUOTA_URI));
         DataNode child = (DataNode) node.getNodes().get(0);
@@ -211,6 +221,50 @@ public class NodeDAOTest {
         assertTrue(optId.isPresent());
         assertFalse(dao.isBranchWritable(optId.get(), "user1", List.of("group99")));
     }
+    
+    @Test
+    public void testIsBranchReadable() {
+
+        List<String> userGroups = List.of("group1");
+        Optional<Long> optId = dao.getNodeId("/test3/m1");
+        assertTrue(optId.isPresent());
+        assertTrue(dao.isBranchReadable(optId.get(), "user3", userGroups));
+
+        optId = dao.getNodeId("/test3");
+        assertTrue(optId.isPresent());
+        assertFalse(dao.isBranchReadable(optId.get(), "user2", userGroups));
+
+        optId = dao.getNodeId("/test3/group1");
+        assertTrue(optId.isPresent());
+        assertTrue(dao.isBranchReadable(optId.get(), "user2", userGroups));
+
+        optId = dao.getNodeId("/test3/group1");
+        assertTrue(optId.isPresent());
+        assertFalse(dao.isBranchReadable(optId.get(), "user1", List.of("group99")));
+    }
+    
+    @Test
+    public void testSetJobId(){
+        Optional<Long> optId = dao.getNodeId("/test3/m1");
+        assertTrue(optId.isPresent());
+        
+        assertFalse(dao.isBranchBusy(optId.get()));
+        
+        dao.setBranchJobId(optId.get(), "pippo1");
+        
+        assertTrue(dao.isBranchBusy(optId.get()));
+        
+        Optional<Long> childId = dao.getNodeId("/test3/m1/m2");
+        assertTrue(childId.isPresent());
+        
+        assertTrue(dao.isBranchBusy(childId.get()));
+        
+        dao.setBranchJobId(optId.get(), null);
+        
+        assertFalse(dao.isBranchBusy(optId.get()));
+        assertFalse(dao.isBranchBusy((childId.get())));    
+               
+    }    
 
     @Test
     public void testMoveNodeBranch() {
@@ -228,7 +282,7 @@ public class NodeDAOTest {
         dao.moveNodeBranch(optSourceId.get(), snd.getDestinationNodeLtreePath());
 
         Optional<Long> optResultId = dao.getNodeId("/test3/group1/m1");
-        assertTrue(optSourceId.isPresent());
+        assertTrue(optResultId.isPresent());
         optSnd = dao.getShortNodeDescriptor("/test3/group1/m1", "user3", List.of("group1"));
         assertEquals("9.17.10", optSnd.get().getDestinationNodeLtreePath());
 
@@ -239,6 +293,35 @@ public class NodeDAOTest {
 
     }
 
+
+    @Test
+    public void testCopyNodeBranch() {
+        // Let's copy /test3/m1 to /test3/group1
+        Optional<Long> optSourceId = dao.getNodeId("/test3/m1");
+        assertTrue(optSourceId.isPresent());
+
+        Optional<Long> optSourceChildId = dao.getNodeId("/test3/m1/m2");
+        assertTrue(optSourceChildId.isPresent());
+
+        Optional<Long> optDestParentId = dao.getNodeId("/test3/group1");
+        assertTrue(optDestParentId.isPresent());
+
+        dao.copyBranch("/test3/m1", "/test3/group1/copy_of_m1");
+        
+        Optional<Long> resultId = dao.getNodeId("/test3/group1/copy_of_m1");
+        assertTrue(resultId.isPresent());
+
+        Optional<Long> recheckSource = dao.getNodeId("/test3/m1");
+        assertTrue(recheckSource.isPresent());
+
+        Optional<Long> resultIdChild = dao.getNodeId("/test3/group1/copy_of_m1/m2");
+        assertTrue(resultIdChild.isPresent());
+
+        Optional<Long> recheckSourceChild = dao.getNodeId("/test3/m1/m2");
+        assertTrue(recheckSourceChild.isPresent());
+
+    }
+
     @Test
     public void testRenameNode() {
         String oldPath = "/test1/f1";
@@ -392,7 +475,30 @@ public class NodeDAOTest {
     public void testGetNodeOsName() {
         assertEquals("f2", dao.getNodeOsName("/test1/f1/f2_renamed"));
         assertEquals("f4", dao.getNodeOsName("/test2/f4"));
-    }
+    }   
+    
+    @Test
+    public void testReleaseNodesByJobId(){
+        Optional<Long> optId = dao.getNodeId("/test3/m1");
+        assertTrue(optId.isPresent());
+        
+        assertFalse(dao.isBranchBusy(optId.get()));
+        
+        dao.setBranchJobId(optId.get(), "pippo1");
+        
+        assertTrue(dao.isBranchBusy(optId.get()));
+        
+        Optional<Long> childId = dao.getNodeId("/test3/m1/m2");
+        assertTrue(childId.isPresent());
+        
+        assertTrue(dao.isBranchBusy(childId.get()));
+        
+        dao.releaseBusyNodesByJobId("pippo1");
+        
+        assertFalse(dao.isBranchBusy(optId.get()));
+        assertFalse(dao.isBranchBusy((childId.get())));    
+               
+    }       
 
     private Property getProperty(String uri, String value) {
         Property property = new Property();
diff --git a/src/test/resources/test-data.sql b/src/test/resources/test-data.sql
index 53f9735b57f32d23b72e5f1bc7527b3dd679a4d7..44a025cd80f440faad28393851b4e25892da41f7 100644
--- a/src/test/resources/test-data.sql
+++ b/src/test/resources/test-data.sql
@@ -33,7 +33,7 @@ INSERT INTO node (parent_path, parent_relative_path, name, sticky, type, creator
 INSERT INTO node (parent_path, parent_relative_path, name, job_id, type, creator_id, is_public, location_id) VALUES ('9', '', 'mbusy', 'job1234', 'container', 'user3', false, 3);      -- /test3/mbusy
 INSERT INTO node (parent_path, parent_relative_path, name, async_trans, type, creator_id, is_public, location_id) VALUES ('9', '', 'masynctrans', true, 'container', 'user3', false, 3);      -- /test3/masynctrans
 INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('9', '', 'asyncloc', 'container', 'user3', false, 1);      -- /test3/asyncloc
-INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, group_write, is_public, location_id) VALUES ('9', '', 'group1', 'container', 'user3','{"group1"}', false, 3);      -- /test3/group1
+INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, group_write, group_read, is_public, location_id) VALUES ('9', '', 'group1', 'container', 'user3', '{"group1"}', '{"group1"}', false, 3);      -- /test3/group1
 
 
 DELETE FROM job;