diff --git a/src/main/java/it/inaf/oats/vospace/BaseNodeController.java b/src/main/java/it/inaf/oats/vospace/BaseNodeController.java index bbc4b403551543f8225bfb7305ac9d567e7eb300..db1bf8d8343b6962e68c28f7534364ec65c999f8 100644 --- a/src/main/java/it/inaf/oats/vospace/BaseNodeController.java +++ b/src/main/java/it/inaf/oats/vospace/BaseNodeController.java @@ -16,6 +16,11 @@ public abstract class BaseNodeController { private HttpServletRequest servletRequest; protected String getPath() { + // This is to allow calls from the code to CreateNodeController + // since request url is not set + if(servletRequest.getRequestURL() == null) + return null; + String requestURL = servletRequest.getRequestURL().toString(); try { return NodeUtils.getPathFromRequestURLString(requestURL); diff --git a/src/main/java/it/inaf/oats/vospace/CreateNodeController.java b/src/main/java/it/inaf/oats/vospace/CreateNodeController.java index fd867116dee09ed525507e0091e65ab6ac94c672..b8cf12ba3717b278c5726c4ce128916234bcebef 100644 --- a/src/main/java/it/inaf/oats/vospace/CreateNodeController.java +++ b/src/main/java/it/inaf/oats/vospace/CreateNodeController.java @@ -36,14 +36,22 @@ public class CreateNodeController extends BaseNodeController { consumes = {MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) public Node createNode(@RequestBody Node node, User principal) { - - String path = getPath(); - - LOG.debug("createNode called for path {}", path); - + + LOG.debug("createNode called for node with URI {}", node.getUri()); + // Validate payload node URI if (!isValidURI(node.getUri())) { throw new InvalidURIException(node.getUri()); + } + + String path; + + if(getPath() == null) { + LOG.debug("createNode called internally with null path"); + path = node.getUri().replaceAll("vos://[^/]+", ""); + } else { + path = getPath(); + LOG.debug("createNode called for path {}", path); } // Check if payload URI is consistent with http request diff --git a/src/main/java/it/inaf/oats/vospace/JobService.java b/src/main/java/it/inaf/oats/vospace/JobService.java index d7eb4c3c38f567fae5b553fe5f73e32aa7778e0a..0b8442ec0ac54b74d3764080020d591ecf94fbee 100644 --- a/src/main/java/it/inaf/oats/vospace/JobService.java +++ b/src/main/java/it/inaf/oats/vospace/JobService.java @@ -105,9 +105,8 @@ public class JobService { handleVoSpaceUrlsListResult(job, transfer); break; case moveNode: - throw new UnsupportedOperationException("Not implemented yet"); - // handleMoveNode(job, transfer); - // break; + handleMoveNode(transfer); + break; default: throw new UnsupportedOperationException("Not implemented yet"); } @@ -147,9 +146,9 @@ public class JobService { uriService.setTransferJobResult(job, transfer); } - private void handleMoveNode(JobSummary job, Transfer transfer) + private void handleMoveNode(Transfer transfer) { - moveService.processMoveJob(job, transfer); + moveService.processMoveJob(transfer); } private JobDirection getJobDirection(Transfer transfer) { diff --git a/src/main/java/it/inaf/oats/vospace/MoveService.java b/src/main/java/it/inaf/oats/vospace/MoveService.java index fbae2fa2ace5f720a032f139cf613deca5cb1167..72708fb220938e75e9074a2318639a3dfdac9628 100644 --- a/src/main/java/it/inaf/oats/vospace/MoveService.java +++ b/src/main/java/it/inaf/oats/vospace/MoveService.java @@ -8,66 +8,125 @@ package it.inaf.oats.vospace; import it.inaf.ia2.aa.data.User; import it.inaf.oats.vospace.datamodel.NodeProperties; import it.inaf.oats.vospace.datamodel.NodeUtils; +import it.inaf.oats.vospace.exception.ContainerNotFoundException; +import it.inaf.oats.vospace.exception.DuplicateNodeException; +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 java.util.List; +import java.util.Optional; import javax.servlet.http.HttpServletRequest; -import net.ivoa.xml.uws.v1.JobSummary; +import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Transfer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; @Service +@EnableTransactionManagement public class MoveService { @Autowired private NodeDAO nodeDao; + @Value("${vospace-authority}") + private String authority; + @Autowired - private HttpServletRequest servletRequest; + private CreateNodeController createNodeController; - public void processMoveJob(JobSummary job, Transfer transfer) { + @Autowired + private HttpServletRequest servletRequest; + + @Transactional(rollbackFor = { Exception.class }) + public void processMoveJob(Transfer transfer) { - // Get Source Path - String sourcePath = transfer.getTarget(); + // Get Source Vos Path + String sourcePath = transfer.getTarget().substring("vos://".length() + authority.length()); - // Get Destination Path (it's in transfer direction) - String destinationPath = transfer.getDirection(); + // Get Destination Vos Path (it's in transfer direction) + String destinationPath = transfer.getDirection().substring("vos://".length() + authority.length()); // Extract User permissions from servlet request User user = (User) servletRequest.getUserPrincipal(); - Long sourceId = nodeDao.getNodeId(sourcePath); - List<Node> branchList = nodeDao.listNodesInBranch(sourceId, true); - - // Check feasibility of move on source branch - if (!isWritePermissionsValid(branchList, user)) { + // Generic common validation for move process job paths + this.validatePath(sourcePath); + this.validatePath(destinationPath); + + // Get source node (this locks it with SELECT ... FOR UPDATE) + Optional<Long> sourceIdOpt = nodeDao.getNodeId(sourcePath); + if (sourceIdOpt.isEmpty()) { + throw new NodeNotFoundException(sourcePath); + } + Long sourceId = sourceIdOpt.get(); + + // Get node branch with root == source. All nodes are locked + // with SELECT ... FOR UPDATE + List<Node> sourceBranchNodeList = nodeDao.listNodesInBranch(sourceId, true); + + // Check feasibility of move for source branch + if (!isWritePermissionsValid(sourceBranchNodeList, user)) { throw new PermissionDeniedException(sourcePath); } - - if(sourcePath.equals(destinationPath)) + + if (sourcePath.equals(destinationPath)) { return; - - if(!isMoveable(branchList)) { + } + + if (!isMoveable(sourceBranchNodeList)) { throw new NodeBusyException(sourcePath); } - + // Set branch at busy nodeDao.setBranchBusy(sourceId, true); - + + // EDGE CASE: a node with the same destination path is created by another + // process in the database between destination check and move. + // This applies also to rename. + // the move process would overwrite it or worse create two nodes with + // different ids and same vos path + // possible solution: check for busyness of parent node when creating + // a new node? May it work and be compliant? - // Compare source and destination paths and see if it's just a rename - if(NodeUtils.getParentPath(sourcePath).equals(NodeUtils.getParentPath(destinationPath))) - { + // check if destination node exists before + if (this.checkNodeExistence(destinationPath)) { + throw new DuplicateNodeException(destinationPath); + } + + // Compare source and destination paths parents and see if it's just a rename + if (NodeUtils.getParentPath(sourcePath) + .equals(NodeUtils.getParentPath(destinationPath))) { + nodeDao.renameNode(sourceId, NodeUtils.getLastPathElement(destinationPath)); + } else { - this.moveNode(sourceId, sourcePath, destinationPath, user); + + Long destParentId; + + Optional<Long> optDest = nodeDao.getNodeId(NodeUtils.getParentPath(destinationPath)); + if (optDest.isEmpty()) { + // Try to create parent container(s) + destParentId = this.createDestination(NodeUtils.getParentPath(destinationPath), user); + } else { + Node parentNode = nodeDao.getNodeById(optDest.get(), true) + .orElseThrow(()-> + new NodeNotFoundException(NodeUtils.getParentPath(destinationPath))); + + this.validateDestinationParentNode(parentNode, user); + destParentId = optDest.get(); + } + + this.moveNode(sourceId, destParentId, NodeUtils.getLastPathElement(destinationPath)); } nodeDao.setBranchBusy(sourceId, false); - + } // All nodes must be writable by the user to have a true @@ -81,7 +140,7 @@ public class MoveService { } - // All nodes must comply to have a true + // All nodes must comply to have a true output private boolean isMoveable(List<Node> list) { return list.stream().allMatch((n) -> { boolean busy = NodeUtils.getIsBusy(n); @@ -93,11 +152,49 @@ public class MoveService { return (!busy && !sticky); }); } - + + private void moveNode(Long sourceId, Long destParentId, String newNodeName) { + nodeDao.moveNodeBranch(sourceId, destParentId); + nodeDao.renameNode(sourceId, newNodeName); + } + + private void validatePath(String path) { + if (path.equals("/")) { + throw new IllegalArgumentException("Cannot move root node or to root node"); + } + } + + private boolean checkNodeExistence(String path) { + Optional<Long> optNodeId = nodeDao.getNodeId(path); + return optNodeId.isPresent(); + } - private void moveNode(Long sourceId, String sourcePath, String destPath, User user) - { + // Returns node id of created destination + private Long createDestination(String path, User user) { + List<String> components = NodeUtils.subPathComponents(path); + + for (int i = 0; i < components.size(); i++) { + if (!this.checkNodeExistence(components.get(i))) { + ContainerNode node = new ContainerNode(); + node.setUri("vos://" + this.authority + components.get(i)); + createNodeController.createNode(node, user); + } + } + return nodeDao.getNodeId(path).orElseThrow(()-> + new InternalFaultException("Unable to create destination at path: "+path)); + + } + + private void validateDestinationParentNode(Node node, User user) { + if (!(node instanceof ContainerNode)) { + throw new ContainerNotFoundException( + NodeUtils.getVosPath(node)); + } + + if (!NodeUtils.checkIfWritable(node, user.getName(), user.getGroups())) { + throw new PermissionDeniedException(NodeUtils.getVosPath(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 4802acc0a4d86b85161d914c50499d8796b1344a..2aaf3937efc56857ade4a6f510399cec5a88514e 100644 --- a/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java +++ b/src/main/java/it/inaf/oats/vospace/persistence/NodeDAO.java @@ -46,7 +46,7 @@ public class NodeDAO { private String authority; private final JdbcTemplate jdbcTemplate; - + @Autowired public NodeDAO(DataSource dataSource) { jdbcTemplate = new JdbcTemplate(dataSource); @@ -231,24 +231,61 @@ public class NodeDAO { return node; } - public Long getNodeId(String nodePath) { + public Optional<Long> getNodeId(String nodeVosPath) { String sql = "SELECT node_id FROM node_vos_path WHERE vos_path = ? FOR UPDATE"; List<Long> nodeIdList = jdbcTemplate.query(conn -> { PreparedStatement ps = conn.prepareStatement(sql); - ps.setString(1, nodePath); + ps.setString(1, nodeVosPath); return ps; }, (row, index) -> { return row.getLong("node_id"); }); - // Node id is - if (nodeIdList.isEmpty()) { - throw new NodeNotFoundException(nodePath); + switch (nodeIdList.size()) { + case 0: + return Optional.empty(); + + case 1: + return Optional.of(nodeIdList.get(0)); + + default: + throw new InternalFaultException("More than 1 node id at path: " + nodeVosPath); } + } + + public Optional<Node> getNodeById(Long nodeId, boolean enforceTapeStoredCheck) { + String sql = "SELECT os.vos_path, loc.location_type, n.node_id, type, async_trans, sticky, busy_state, creator_id, group_read, group_write,\n" + + "is_public, content_length, created_on, last_modified, accept_views, provide_views\n" + + "FROM node n\n" + + "JOIN node_vos_path os ON n.node_id = os.node_id\n" + + "JOIN location loc ON n.location_id = loc.location_id\n" + + "WHERE n.node_id = ?\n" + + "FOR UPDATE"; + + List<Node> result = jdbcTemplate.query(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setLong(1, nodeId); + return ps; + }, (row, index) -> { + if (enforceTapeStoredCheck && row.getString("location_type").equals("async")) { + throw new InternalFaultException( + "Node id: " + nodeId + " has async location type. " + + "Failure due to enforced check."); + } + return getNodeFromResultSet(row); + }); + + switch (result.size()) { + case 0: + return Optional.empty(); - // Node id is PRIMARY KEY: uniqueness is enforced at DB level - return nodeIdList.get(0); + case 1: + return Optional.of(result.get(0)); + + default: + throw new InternalFaultException("Multiple nodes with id: " + nodeId); + } } // First node is the root node @@ -268,7 +305,8 @@ public class NodeDAO { }, (row, index) -> { if (enforceTapeStoredCheck && row.getString("location_type").equals("async")) { throw new InternalFaultException( - "At least one node in branch has async location type. " + "At least one node in branch with root id: " + rootNodeId + + " has async location type. " + "Failure due to enforced check."); } return getNodeFromResultSet(row); @@ -302,6 +340,72 @@ public class NodeDAO { }); } + + /* + // unused? + public Optional<String> getNodeLtreePathById(Long nodeId) { + String sql = "SELECT path FROM node WHERE node_id = ? FOR UPDATE"; + + List<String> pathList = jdbcTemplate.query(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setLong(1, nodeId); + return ps; + }, (row, index) -> { + return row.getString("path"); + }); + + switch (pathList.size()) { + case 0: + return Optional.empty(); + + case 1: + return Optional.of(pathList.get(0)); + + default: + throw new InternalFaultException("More than one id = " + nodeId); + } + } + + //remove + public String getParentPath(Long id) { + String sql = "SELECT parent_path FROM node WHERE node_id = ?"; + + List<String> nodeIdList = jdbcTemplate.query(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setLong(1, id); + return ps; + }, (row, index) -> { + return row.getString("parent_path"); + }); + + if(nodeIdList.size() > 0) + { + return nodeIdList.get(0); + } else { + return null; + } + + + } + */ + + public void moveNodeBranch(Long sourceRootId, Long destinationParentId) + { + String sql = "UPDATE node\n"+ + "SET parent_path = ((SELECT path FROM node WHERE node_id = ?) ||\n"+ + "(CASE WHEN node_id = ? THEN '' ELSE subpath(parent_path, index(parent_path,(?::varchar)::ltree)) END))\n" + + "WHERE path ~ ('*.' || ? || '.*')::lquery"; + + jdbcTemplate.update(conn -> { + PreparedStatement ps = conn.prepareStatement(sql); + ps.setLong(1, destinationParentId); + ps.setLong(2, sourceRootId); + ps.setLong(3, sourceRootId); + ps.setLong(4, sourceRootId); + return ps; + }); + + } public void deleteNode(String path) { int nodesWithPath = countNodesWithPath(path); diff --git a/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java b/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java new file mode 100644 index 0000000000000000000000000000000000000000..155bc1004103b6fc7c3536c39c4fd3c9ec59e9c4 --- /dev/null +++ b/src/test/java/it/inaf/oats/vospace/MoveServiceTest.java @@ -0,0 +1,296 @@ +/* + * 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.NodeProperties; +import it.inaf.oats.vospace.exception.DuplicateNodeException; +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.DataSourceConfigSingleton; +import it.inaf.oats.vospace.persistence.NodeDAO; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import net.ivoa.xml.vospace.v2.ContainerNode; +import net.ivoa.xml.vospace.v2.Property; +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest +@AutoConfigureMockMvc +@ContextConfiguration(classes = {DataSourceConfigSingleton.class, MoveServiceTest.TestConfig.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 MoveServiceTest { + + @Value("${vospace-authority}") + private String authority; + + @Autowired + private MoveService moveService; + + @Autowired + private NodeDAO nodeDao; + + @Autowired + private HttpServletRequest servletRequest; + + @TestConfiguration + public static class TestConfig { + + /** + * Necessary because MockBean doesn't work with HttpServletRequest. + */ + @Bean + @Primary + public HttpServletRequest servletRequest() { + HttpServletRequest request = mock(HttpServletRequest.class); + User user = new User().setUserId("anonymous"); + when(request.getUserPrincipal()).thenReturn(user); + return request; + } + } + + @Test + @Order(1) + public void moveRootTest() { + + assertThrows(IllegalArgumentException.class, () -> { + moveService.processMoveJob(getTransfer("/", "/pippo")); + } + ); + + assertThrows(IllegalArgumentException.class, () -> { + moveService.processMoveJob(getTransfer("/pippo", "/")); + } + ); + + } + + @Test + @Order(2) + public void testNonExistingSourceNode() { + assertThrows(NodeNotFoundException.class, () -> { + moveService.processMoveJob(getTransfer("/pippo", "/test2")); + } + ); + } + + @Test + @Order(3) + public void testMoveDeniedOnAsync() { + assertThrows(InternalFaultException.class, () -> { + moveService.processMoveJob(getTransfer("/test1", "/test4")); + } + ); + } + + @Test + @Order(4) + public void testPermissionDenied() { + User user = mock(User.class); + when(user.getName()).thenReturn("user1"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + assertThrows(PermissionDeniedException.class, () -> { + moveService.processMoveJob(getTransfer("/test3/m1", "/test4")); + } + ); + } + + @Test + @Order(5) + public void testDestinationNodeAlreadyExisting() { + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + assertThrows(DuplicateNodeException.class, () -> { + moveService.processMoveJob(getTransfer("/test3/m1", "/test4")); + } + ); + } + + @Test + @Order(6) + public void testBusyNodeInSourceBranch() { + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + nodeDao.setBranchBusy(nodeDao.getNodeId("/test3/m1/m2").orElseThrow(), true); + + assertThrows(NodeBusyException.class, () -> { + moveService.processMoveJob(getTransfer("/test3/m1", "/test4")); + } + ); + + nodeDao.setBranchBusy(nodeDao.getNodeId("/test3/m1/m2").orElseThrow(), false); + } + + @Test + @Order(7) + public void testNoMoveOnSticky() { + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + assertThrows(NodeBusyException.class, () -> { + moveService.processMoveJob(getTransfer("/test3/mstick", "/test4")); + } + ); + + } + + @Test + @Order(8) + public void testRenameNode() { + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + Optional<Long> sourceId = nodeDao.getNodeId("/test3/m1"); + assertTrue(sourceId.isPresent()); + Optional<Long> childId = nodeDao.getNodeId("/test3/m1/m2"); + assertTrue(childId.isPresent()); + // Rename + moveService.processMoveJob(getTransfer("/test3/m1", "/test3/m1ren")); + + Optional<Long> checkSourceId = nodeDao.getNodeId("/test3/m1"); + assertTrue(checkSourceId.isEmpty()); + + Optional<Long> newSourceId = nodeDao.getNodeId("/test3/m1ren"); + assertTrue(newSourceId.isPresent()); + assertEquals(sourceId.get(), newSourceId.get()); + + Optional<Long> newChildId = nodeDao.getNodeId("/test3/m1ren/m2"); + assertTrue(newChildId.isPresent()); + assertEquals(childId.get(), newChildId.get()); + + } + + @Test + @Order(9) + public void testMoveToExistingParent(){ + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + // 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> destParentId = nodeDao.getNodeId("/test4"); + assertTrue(destParentId.isPresent()); + + Optional<Long> destId = nodeDao.getNodeId("/test4/dest1"); + assertTrue(destId.isEmpty()); + + // move + moveService.processMoveJob(getTransfer("/test3/m1", "/test4/dest1")); + + // source has been moved + Optional<Long> oldSourceId = nodeDao.getNodeId("/test3/m1"); + assertTrue(oldSourceId.isEmpty()); + Optional<Long> oldChildId = nodeDao.getNodeId("/test3/m1/m2"); + assertTrue(oldChildId.isEmpty()); + + Optional<Long> newSourceId = nodeDao.getNodeId("/test4/dest1"); + assertTrue(newSourceId.isPresent()); + assertEquals(sourceId.get(), newSourceId.get()); + + Optional<Long> newChildId = nodeDao.getNodeId("/test4/dest1/m2"); + assertTrue(newChildId.isPresent()); + assertEquals(childId.get(), newChildId.get()); + + } + + @Test + @Order(10) + public void testMoveToUnexistingParent() { + User user = mock(User.class); + when(user.getName()).thenReturn("user3"); + when(servletRequest.getUserPrincipal()).thenReturn(user); + + Optional<Long> sourceId = nodeDao.getNodeId("/test3/m1"); + assertTrue(sourceId.isPresent()); + Optional<Long> childId = nodeDao.getNodeId("/test3/m1/m2"); + assertTrue(childId.isPresent()); + + Optional<Long> destParentId = nodeDao.getNodeId("/test4"); + assertTrue(destParentId.isPresent()); + + Optional<Long> destCreatemeId = nodeDao.getNodeId("/test4/createme"); + assertTrue(destCreatemeId.isEmpty()); + + // Rename + moveService.processMoveJob(getTransfer("/test3/m1", "/test4/createme/dest1")); + + Optional<Long> checkSourceId = nodeDao.getNodeId("/test3/m1"); + assertTrue(checkSourceId.isEmpty()); + + Optional<Long> newCreatemeId = nodeDao.getNodeId("/test4/createme"); + assertTrue(newCreatemeId.isPresent()); + + Optional<Long> newSourceId = nodeDao.getNodeId("/test4/createme/dest1"); + assertTrue(newSourceId.isPresent()); + assertEquals(sourceId.get(), newSourceId.get()); + + Optional<Long> newChildId = nodeDao.getNodeId("/test4/createme/dest1/m2"); + assertTrue(newChildId.isPresent()); + assertEquals(childId.get(), newChildId.get()); + + } + + 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 ContainerNode getContainerNode(String vosPath, String owner, String writeGroups) + { + ContainerNode node = new ContainerNode(); + node.setUri("vos://"+this.authority+vosPath); + Property ownerProp = new Property(); + ownerProp.setUri(NodeProperties.CREATOR_URI); + ownerProp.setValue(owner); + + Property writeGroupsProp = new Property(); + writeGroupsProp.setUri(NodeProperties.GROUP_WRITE_URI); + writeGroupsProp.setValue(writeGroups); + + node.getProperties().add(ownerProp); + node.getProperties().add(writeGroupsProp); + + return node; + } + +} diff --git a/src/test/java/it/inaf/oats/vospace/persistence/DataSourceConfigSingleton.java b/src/test/java/it/inaf/oats/vospace/persistence/DataSourceConfigSingleton.java new file mode 100644 index 0000000000000000000000000000000000000000..2430defaaa04f651ad4286d3d005fafe7d2b897a --- /dev/null +++ b/src/test/java/it/inaf/oats/vospace/persistence/DataSourceConfigSingleton.java @@ -0,0 +1,122 @@ +/* + * 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.persistence; + +import com.opentable.db.postgres.embedded.EmbeddedPostgres; +import com.opentable.db.postgres.embedded.PgBinaryResolver; +import com.opentable.db.postgres.embedded.UncompressBundleDirectoryResolver; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.sql.DataSource; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +/** + * Generates a DataSource that can be used for testing DAO classes. It loads an + * embedded Postgres database and fills it using the data from + * vospace-transfer-service repository (folder must exists; it location can be + * configured using the init_database_scripts_path in test.properties). + */ +@TestConfiguration +public class DataSourceConfigSingleton { + + @Value("${init_database_scripts_path}") + private String scriptPath; + + @Bean + @Primary + public DataSource dataSource() throws Exception { + DataSource embeddedPostgresDS = EmbeddedPostgres.builder() + .setPgDirectoryResolver(new UncompressBundleDirectoryResolver(new CustomPostgresBinaryResolver())) + .start().getPostgresDatabase(); + + initDatabase(embeddedPostgresDS); + + return embeddedPostgresDS; + } + + private class CustomPostgresBinaryResolver implements PgBinaryResolver { + + /** + * Loads specific embedded Postgres version. + */ + @Override + public InputStream getPgBinary(String system, String architecture) throws IOException { + ClassPathResource resource = new ClassPathResource(String.format("postgres-%s-%s.txz", system.toLowerCase(), architecture)); + return resource.getInputStream(); + } + } + + /** + * Loads SQL scripts for database initialization from + * vospace-transfer-service repo directory. + */ + private void initDatabase(DataSource dataSource) throws Exception { + try ( Connection conn = dataSource.getConnection()) { + + File currentDir = new File(DataSourceConfigSingleton.class.getClassLoader().getResource(".").getFile()); + File scriptDir = currentDir.toPath().resolve(scriptPath).toFile().getCanonicalFile(); + + assertTrue(scriptDir.exists(), "DAO tests require " + scriptDir.getAbsolutePath() + " to exists.\n" + + "Please clone the repository from https://www.ict.inaf.it/gitlab/vospace/vospace-file-catalog.git"); + + File[] scripts = scriptDir.listFiles(f -> f.getName().endsWith(".sql")); + Arrays.sort(scripts); // sort alphabetically + + for (File script : scripts) { + ByteArrayResource scriptResource = replaceDollarQuoting(script.toPath()); + ScriptUtils.executeSqlScript(conn, scriptResource); + } + + ScriptUtils.executeSqlScript(conn, new ClassPathResource("test-data.sql")); + } + } + + /** + * It seems that dollar quoting (used in UDF) is broken in JDBC. Replacing + * it with single quotes solves the problem. We replace the quoting here + * instead of inside the original files because dollar quoting provides a + * better visibility. + */ + private ByteArrayResource replaceDollarQuoting(Path sqlScriptPath) throws Exception { + + String scriptContent = Files.readString(sqlScriptPath); + + if (scriptContent.contains("$func$")) { + + String func = extractFunctionDefinition(scriptContent); + + String originalFunction = "$func$" + func + "$func$"; + String newFunction = "'" + func.replaceAll("'", "''") + "'"; + + scriptContent = scriptContent.replace(originalFunction, newFunction); + } + + return new ByteArrayResource(scriptContent.getBytes()); + } + + private String extractFunctionDefinition(String scriptContent) { + Pattern pattern = Pattern.compile("\\$func\\$(.*?)\\$func\\$", Pattern.DOTALL); + Matcher matcher = pattern.matcher(scriptContent); + if (matcher.find()) { + return matcher.group(1); + } + throw new IllegalArgumentException(scriptContent + " doesn't contain $func$"); + } +} 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 501ca5455d4411c8de0aab7f57a613b8fb2cf4ec..31eedd2efc8e7c603e5611d61a848dbce68923fa 100644 --- a/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java +++ b/src/test/java/it/inaf/oats/vospace/persistence/NodeDAOTest.java @@ -8,11 +8,11 @@ package it.inaf.oats.vospace.persistence; import it.inaf.oats.vospace.datamodel.NodeProperties; import it.inaf.oats.vospace.datamodel.NodeUtils; import it.inaf.oats.vospace.exception.InternalFaultException; -import it.inaf.oats.vospace.exception.NodeNotFoundException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import javax.sql.DataSource; import net.ivoa.xml.vospace.v2.ContainerNode; @@ -21,7 +21,6 @@ import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Property; import net.ivoa.xml.vospace.v2.View; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -66,7 +65,7 @@ public class NodeDAOTest { @Test public void testListNode() { ContainerNode root = (ContainerNode) dao.listNode("/").get(); - assertEquals(2, root.getNodes().size()); + assertEquals(4, root.getNodes().size()); assertEquals("true", NodeProperties.getNodePropertyByURI(root, NodeProperties.PUBLIC_READ_URI)); assertEquals("0", NodeProperties.getNodePropertyByURI(root, NodeProperties.LENGTH_URI)); @@ -80,18 +79,40 @@ public class NodeDAOTest { @Test public void testGetNodeId() { - assertEquals(2, dao.getNodeId("/test1")); - assertEquals(3, dao.getNodeId("/test1/f1")); - - assertThrows(NodeNotFoundException.class, + Optional<Long> id1 = dao.getNodeId("/test1"); + assertTrue(id1.isPresent()); + assertEquals(2, id1.get()); + + Optional<Long> id2 = dao.getNodeId("/test1/f1"); + assertTrue(id2.isPresent()); + assertEquals(3, id2.get()); + + Optional<Long> id3 = dao.getNodeId("/pippo123123"); + assertTrue(id3.isEmpty()); + } + + @Test + public void testGetNodeById() { + Optional<Long> id1 = dao.getNodeId("/test1/f1"); + assertTrue(id1.isPresent()); + + assertThrows(InternalFaultException.class, () -> { - dao.getNodeId("/pippo123123"); + dao.getNodeById(id1.get(), true); }); - } + + Optional<Node> opt1 = dao.getNodeById(id1.get(), false); + + assertTrue(opt1.isPresent()); + assertTrue(NodeUtils.getVosPath(opt1.get()).equals("/test1/f1")); + } @Test public void testListNodesInBranch() { - List<Node> result = dao.listNodesInBranch(dao.getNodeId("/test1/f1"), false); + Optional<Long> id1 = dao.getNodeId("/test1/f1"); + assertTrue(id1.isPresent()); + + List<Node> result = dao.listNodesInBranch(id1.get(), false); assertEquals(3, result.size()); // Check if list has root node at index 0 Node root = result.get(0); @@ -99,16 +120,18 @@ public class NodeDAOTest { assertThrows(InternalFaultException.class, () -> { - dao.listNodesInBranch(dao.getNodeId("/test1/f1"), true); + dao.listNodesInBranch(id1.get(), true); }); } @Test public void testSetBranchBusy() { - Long rootId = dao.getNodeId("/test1/f1"); - dao.setBranchBusy(rootId, true); - List<Node> busyList = dao.listNodesInBranch(rootId, false); + Optional<Long> rootId = dao.getNodeId("/test1/f1"); + assertTrue(rootId.isPresent()); + + dao.setBranchBusy(rootId.get(), true); + List<Node> busyList = dao.listNodesInBranch(rootId.get(), false); boolean busyTrue = busyList.stream().allMatch((n) -> { if (n instanceof DataNode) { return ((DataNode) n).isBusy(); @@ -120,9 +143,9 @@ public class NodeDAOTest { assertTrue(busyTrue); - dao.setBranchBusy(rootId, false); + dao.setBranchBusy(rootId.get(), false); - busyList = dao.listNodesInBranch(rootId, false); + busyList = dao.listNodesInBranch(rootId.get(), false); boolean busyFalse = busyList.stream().allMatch((n) -> { if (n instanceof DataNode) { @@ -146,9 +169,10 @@ public class NodeDAOTest { assertTrue(dao.listNode(oldPath).isPresent()); assertTrue(dao.listNode(oldPathChild).isPresent()); - Long rootId = dao.getNodeId(oldPath); + Optional<Long> rootId = dao.getNodeId(oldPath); + assertTrue(rootId.isPresent()); - dao.renameNode(rootId, "f_pippo"); + dao.renameNode(rootId.get(), "f_pippo"); assertTrue(dao.listNode(oldPath).isEmpty()); assertTrue(dao.listNode(oldPathChild).isEmpty()); @@ -157,6 +181,39 @@ public class NodeDAOTest { assertTrue(dao.listNode(newPathChild).isPresent()); } + + @Test + public void testMoveNodeBranch() { + Optional<Long> optSourceId = dao.getNodeId("/test1/f1"); + assertTrue(optSourceId.isPresent()); + + Optional<Long> optSourceId1 = dao.getNodeId("/test1/f1/f2_renamed"); + assertTrue(optSourceId1.isPresent()); + + Optional<Long> optSourceId2 = dao.getNodeId("/test1/f1/f2_renamed/f3"); + assertTrue(optSourceId2.isPresent()); + + Optional<Long> optDestId = dao.getNodeId("/test2/f4"); + assertTrue(optDestId.isPresent()); + + dao.moveNodeBranch(optSourceId.get(), optDestId.get()); + + Optional<Long> newOptSourceId = dao.getNodeId("/test1/f1"); + assertTrue(newOptSourceId.isEmpty()); + + Optional<Long> dest = dao.getNodeId("/test2/f4/f1"); + assertTrue(dest.isPresent()); + assertEquals(dest.get(), optSourceId.get()); + + Optional<Long> dest1 = dao.getNodeId("/test2/f4/f1/f2_renamed"); + assertTrue(dest1.isPresent()); + assertEquals(dest1.get(), optSourceId1.get()); + + Optional<Long> dest2 = dao.getNodeId("/test2/f4/f1/f2_renamed/f3"); + assertTrue(dest2.isPresent()); + assertEquals(dest2.get(), optSourceId2.get()); + + } @Test public void testCountNodeWithPath() { diff --git a/src/test/resources/test-data.sql b/src/test/resources/test-data.sql index e31766b9478c847556509ba60f5feafb1b471da3..adf89ffc32789671643f0ee3dff4190533043f90 100644 --- a/src/test/resources/test-data.sql +++ b/src/test/resources/test-data.sql @@ -23,6 +23,14 @@ INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_ INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('6', '', 'f4', 'container', 'user2', true, 1); -- /test2/f4 (rel: /f4) INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('6', '', 'f5', 'container', 'user2', true, 1); -- /test2/f5 (rel: /f5) +INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('', NULL, 'test3', 'container', 'user3', false, 3); -- /test3 +INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('9', '', 'm1', 'container', 'user3', false, 3); -- /test3/m1 +INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('9.10', '', 'm2', 'container', 'user3', false, 3); -- /test3/m1/m2 + +INSERT INTO node (parent_path, parent_relative_path, name, type, creator_id, is_public, location_id) VALUES ('', NULL, 'test4', 'container', 'user3', false, 3); -- /test4 + +INSERT INTO node (parent_path, parent_relative_path, name, sticky, type, creator_id, is_public, location_id) VALUES ('9', '', 'mstick', true, 'container', 'user3', false, 3); -- /test3/mstick + DELETE FROM job; INSERT INTO job (job_id, owner_id, job_type, phase, start_time, end_time, creation_time, job_info, results) VALUES ('pippo1', 'user1', 'pullFromVoSpace', 'ARCHIVED', NULL, NULL, '2011-06-22 19:10:25', NULL, NULL);