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;