/* * Licensed to DuraSpace under one or more contributor license agreements. * See the NOTICE file distributed with this work for additional information * regarding copyright ownership. * * DuraSpace licenses this file to you under the Apache License, * Version 2.0 (the "License"); you may not use this file except in * compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.fcrepo.integration.http.api; import static org.apache.jena.graph.Node.ANY; import static org.apache.jena.graph.NodeFactory.createLiteral; import static org.apache.jena.graph.NodeFactory.createURI; import static java.lang.Math.min; import static java.lang.Thread.sleep; import static java.util.Arrays.stream; import static java.util.UUID.randomUUID; import static java.util.regex.Pattern.compile; import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; import static javax.ws.rs.core.HttpHeaders.LINK; import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL; import static javax.ws.rs.core.Response.Status.CREATED; import static javax.ws.rs.core.Response.Status.GONE; import static javax.ws.rs.core.Response.Status.NOT_FOUND; import static javax.ws.rs.core.Response.Status.NO_CONTENT; import static javax.ws.rs.core.Response.Status.OK; import static org.apache.http.util.EntityUtils.consume; import static org.apache.jena.vocabulary.DC.title; import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.DEFAULT_TIMEOUT; import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.TIMEOUT_SYSTEM_PROPERTY; import static org.fcrepo.kernel.modeshape.services.BatchServiceImpl.REAP_INTERVAL; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import java.io.IOException; import org.fcrepo.http.commons.test.util.CloseableDataset; import org.apache.http.Header; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.entity.StringEntity; import org.junit.Ignore; import org.junit.Test; /** * <p>FedoraTransactionsIT class.</p> * * @author awoods */ public class FedoraTransactionsIT extends AbstractResourceIT { @Test public void testCreateTransaction() throws IOException { final String location = createTransaction(); logger.info("Got location {}", location); assertTrue("Expected Location header to send us to root node path within the transaction", compile("tx:[0-9a-f-]+$").matcher(location).find()); } @Test public void testRequestsInTransactionThatDoestExist() { /* create a tx */ assertEquals(GONE.getStatusCode(), getStatus(new HttpPost(serverAddress + "tx:123/objects"))); } @Test public void testCreateAndTimeoutTransaction() throws IOException, InterruptedException { /* create a short-lived tx */ final long testTimeout = min(500, REAP_INTERVAL / 2); System.setProperty(TIMEOUT_SYSTEM_PROPERTY, Long.toString(testTimeout)); /* create a tx */ final String location = createTransaction(); try (CloseableHttpResponse resp = execute(new HttpGet(location))) { assertEquals(OK.getStatusCode(), getStatus(resp)); assertTrue(stream(resp.getHeaders(LINK)).anyMatch( i -> i.getValue().contains("<" + serverAddress + ">;rel=\"canonical\""))); consume(resp.getEntity()); } sleep(REAP_INTERVAL * 2); try { assertEquals("Transaction did not expire", GONE.getStatusCode(), getStatus(new HttpGet(location))); } finally { System.setProperty(TIMEOUT_SYSTEM_PROPERTY, DEFAULT_TIMEOUT); System.clearProperty("fcrepo.transactions.timeout"); } } @Test public void testCreateDoStuffAndRollbackTransaction() throws IOException { /* create a tx */ final HttpPost createTx = new HttpPost(serverAddress + "fcr:tx"); final String txLocation; try (final CloseableHttpResponse response = execute(createTx)) { assertEquals(CREATED.getStatusCode(), getStatus(response)); txLocation = getLocation(response); } /* create a new object inside the tx */ final HttpPost postNew = new HttpPost(txLocation); final String id = getRandomUniqueId(); postNew.addHeader("Slug", id); try (CloseableHttpResponse resp = execute(postNew)) { assertEquals(CREATED.getStatusCode(), getStatus(resp)); } /* fetch the created tx from the endpoint */ try (final CloseableDataset dataset = getDataset(new HttpGet(txLocation + "/" + id))) { assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(txLocation + "/" + id), ANY, ANY)); } /* fetch the created tx from the endpoint */ assertEquals("Expected to not find our object within the scope of the transaction", NOT_FOUND.getStatusCode(), getStatus(new HttpGet(serverAddress + "/" + id))); /* and rollback */ assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(txLocation + "/fcr:tx/fcr:rollback"))); } @Test public void testTransactionKeepAlive() throws IOException { /* create a tx */ try (final CloseableHttpResponse response = execute(new HttpPost(serverAddress + "fcr:tx"))) { assertEquals(CREATED.getStatusCode(), getStatus(response)); assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(getLocation(response) + "/fcr:tx"))); } } @Test public void testCreateDoStuffAndCommitTransaction() throws IOException { /* create a tx */ final String txLocation = createTransaction(); /* create a new object inside the tx */ final String objectInTxCommit = getRandomUniqueId(); final HttpPost postNew = new HttpPost(txLocation); postNew.addHeader("Slug", objectInTxCommit); assertEquals(CREATED.getStatusCode(), getStatus(postNew)); /* fetch the created tx from the endpoint */ try (CloseableDataset dataset = getDataset(new HttpGet(txLocation + "/" + objectInTxCommit))) { assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(txLocation + "/" + objectInTxCommit), ANY, ANY)); } /* fetch the object-in-tx outside of the tx */ assertEquals("Expected to not find our object within the scope of the transaction", NOT_FOUND.getStatusCode(), getStatus(new HttpGet(serverAddress + objectInTxCommit))); /* and commit */ assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(txLocation + "/fcr:tx/fcr:commit"))); /* fetch the object-in-tx outside of the tx after it has been committed */ try (CloseableDataset dataset = getDataset(new HttpGet(serverAddress + objectInTxCommit))) { assertTrue("Expected to find our object after the transaction was committed", dataset.asDatasetGraph().contains(ANY, createURI(serverAddress + objectInTxCommit), ANY, ANY)); } } @Test public void testCreateDoStuffAndCommitTransactionSeparateConnections() throws IOException { /* create a tx */ final String txLocation = createTransaction(); /* create a new object inside the tx */ final String objectInTxCommit = randomUUID().toString(); final HttpPost postNew = new HttpPost(txLocation); postNew.addHeader("Slug", objectInTxCommit); assertEquals(CREATED.getStatusCode(), getStatus(postNew)); /* fetch the created tx from the endpoint */ try (CloseableDataset dataset = getDataset(new HttpGet(txLocation + "/" + objectInTxCommit))) { assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(txLocation + "/" + objectInTxCommit), ANY, ANY)); } /* fetch the object-in-tx outside of the tx */ assertEquals("Expected to not find our object within the scope of the transaction", NOT_FOUND.getStatusCode(), getStatus(new HttpGet(serverAddress + objectInTxCommit))); /* and commit */ assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(txLocation + "/fcr:tx/fcr:commit"))); /* fetch the object-in-tx outside of the tx after it has been committed */ try (CloseableDataset dataset = getDataset(new HttpGet(serverAddress + objectInTxCommit))) { assertTrue("Expected to find our object after the transaction was committed", dataset.asDatasetGraph().contains(ANY, createURI(serverAddress + objectInTxCommit), ANY, ANY)); } } /** * Tests whether a Sparql update is visible within a transaction and if the update is made persistent along with * the commit. * * @throws IOException exception thrown during this function */ @Test public void testIngestNewWithSparqlPatchWithinTransaction() throws IOException { final String objectInTxCommit = getRandomUniqueId(); /* create new tx */ final String txLocation = createTransaction(); final HttpPost postNew = new HttpPost(txLocation); postNew.addHeader("Slug", objectInTxCommit); final String newObjectLocation; try (CloseableHttpResponse resp = execute(postNew)) { assertEquals(CREATED.getStatusCode(), getStatus(resp)); newObjectLocation = getLocation(resp); } /* update sparql */ final HttpPatch method = new HttpPatch(newObjectLocation); method.addHeader(CONTENT_TYPE, "application/sparql-update"); final String newTitle = "this is a new title"; method.setEntity(new StringEntity("INSERT { <> <http://purl.org/dc/elements/1.1/title> \"" + newTitle + "\" } WHERE {}")); assertEquals("Didn't get a NO CONTENT status!", NO_CONTENT.getStatusCode(), getStatus(method)); /* make sure the change was made within the tx */ try (final CloseableDataset dataset = getDataset(new HttpGet(newObjectLocation))) { assertTrue("The sparql update did not succeed within a transaction", dataset.asDatasetGraph().contains(ANY, createURI(newObjectLocation), title.asNode(), createLiteral(newTitle))); } /* commit */ assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(txLocation + "/fcr:tx/fcr:commit"))); /* it must exist after commit */ try (final CloseableDataset dataset = getDataset(new HttpGet(serverAddress + objectInTxCommit))) { assertTrue("The inserted triple does not exist after the transaction has committed", dataset.asDatasetGraph().contains(ANY, ANY, title.asNode(), createLiteral(newTitle))); } } @Test public void testGetNonExistingObject() throws IOException { final String txLocation = createTransaction(); final String newObjectLocation = txLocation + "/idontexist"; assertEquals("Status should be NOT FOUND", NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newObjectLocation))); } /** * Tests that transactions cannot be hijacked * * @throws IOException exception thrown during this function */ @Test public void testTransactionHijackingNotPossible() throws IOException { /* "fedoraAdmin" creates a transaction */ final String txLocation; try (final CloseableHttpResponse response = executeWithBasicAuth(new HttpPost(serverAddress + "fcr:tx"), "fedoraAdmin", "fedoraAdmin")) { assertEquals("Status should be CREATED after creating a transaction with user fedoraAdmin", CREATED.getStatusCode(), getStatus(response)); txLocation = getLocation(response); } /* "fedoraUser" puts to "fedoraAdmin"'s transaction and fails */ try (final CloseableHttpResponse responseFedoraUser = executeWithBasicAuth(new HttpPut(txLocation), "fedoraUser", "fedoraUser")) { assertEquals("Status should be GONE because putting on a transaction of a different user is not allowed", GONE.getStatusCode(), getStatus(responseFedoraUser)); } /* anonymous user puts to "fedoraAdmin"'s transaction and fails */ assertEquals("Status should be GONE because putting on a transaction of a different user is not allowed", GONE.getStatusCode(), getStatus(new HttpPut(txLocation))); /* transaction is still intact and "fedoraAdmin" - the owner - can successfully put to it */ try (final CloseableHttpResponse responseFromPutToTx = executeWithBasicAuth( new HttpPut(txLocation + "/" + getRandomUniqueId()), "fedoraAdmin", "fedoraAdmin")) { assertEquals("Status should be CREATED after putting", CREATED.getStatusCode(), getStatus(responseFromPutToTx)); } } /** * Tests that transactions cannot be hijacked, even if created by an anonymous user * * @throws IOException exception thrown during this function */ @Test public void testTransactionHijackingNotPossibleAnoymous() throws IOException { /* anonymous user creates a transaction */ final String txLocation = createTransaction(); /* fedoraAdmin attempts to puts to anonymous transaction and fails */ try (final CloseableHttpResponse responseFedoraAdmin = executeWithBasicAuth(new HttpPut(txLocation), "fedoraAdmin", "fedoraAdmin")) { assertEquals( "Status should be GONE because putting on a transaction of a different user is not permitted", GONE.getStatusCode(), getStatus(responseFedoraAdmin)); } /* fedoraUser attempts to put to anonymous transaction and fails */ try (final CloseableHttpResponse responseFedoraUser = executeWithBasicAuth(new HttpPut(txLocation), "fedoraUser", "fedoraUser")) { assertEquals("Status should be GONE because putting on a transaction of a different user isn't permitted", GONE.getStatusCode(), getStatus(responseFedoraUser)); } /* transaction is still intact and any anonymous user can successfully put to it */ assertEquals("Status should be CREATED after putting", CREATED.getStatusCode(), getStatus(new HttpPut(txLocation + "/" + getRandomUniqueId()))); } /** * Tests that caching headers are disabled during transactions. The Last-Modified date is only updated when * Modeshape's <code>Session#save()</code> is invoked. Since this operation is not invoked during a Fedora * transaction, the Last-Modified date never gets updated during a transaction and the delivered content may be * stale. Etag won't work either because it is directly derived from Last-Modified. * * @throws IOException exception thrown during this function */ @Test public void testNoCachingHeadersDuringTransaction() throws IOException { final String txLocation = createTransaction(); final String location; try (final CloseableHttpResponse resp = execute(new HttpPost(txLocation))) { assertFalse("Last-Modified musn't be present during a transaction", resp.containsHeader("Last-Modified")); assertFalse("ETag must not be present during a transaction", resp.containsHeader("ETag")); // Assert Cache-Control headers are present to invalidate caches location = getLocation(resp); } try (final CloseableHttpResponse responseFromGet = execute(new HttpGet(location))) { final Header[] headers = responseFromGet.getHeaders(CACHE_CONTROL); assertEquals("Two cache control headers expected: ", 2, headers.length); assertEquals(CACHE_CONTROL + "expected", CACHE_CONTROL, headers[0].getName()); assertEquals(CACHE_CONTROL + "expected", CACHE_CONTROL, headers[1].getName()); assertEquals("must-revalidate expected", "must-revalidate", headers[0].getValue()); assertEquals("max-age=0 expected", "max-age=0", headers[1].getValue()); consume(responseFromGet.getEntity()); } } /** * Tests that transactions are treated as atomic with regards to nodes. A common use case for applications written * against fedora is that an operation checks some property of a fedora object and acts on it accordingly. In * order for this to work in a multi-client or multi-threaded environment that comparison+action combination needs * to be atomic. Imagine a scenario where we have one process that deletes all objects in the repository that * don't have a "preserve" property set to the literal "true", and we have any number of other clients that add * such a property. We want to ensure that there is no way for a client to successfully add this property between * when the "deleter" process has determined that no such property exists and when it deletes the object. In other * words, if there are only clients adding properties and the "deleter" deleting objects it should not be possible * for an object to be deleted if a client has added a title and received a successful http response code. * * @throws IOException exception thrown during this function */ @Test @Ignore("Until we implement some kind of record level locking.") public void testTransactionAndConcurrentConflictingUpdate() throws IOException { final String preserveProperty = "preserve"; final String preserveValue = "true"; /* create the object in question */ final String objId = getRandomUniqueId(); createObject(objId); /* create the deleter transaction */ final String deleterTxLocation = createTransaction(); final String deleterTxId = deleterTxLocation.substring(serverAddress.length()); /* assert that the object is eligible for delete in the transaction */ verifyProperty("No preserve property should be set!", objId, deleterTxId, preserveProperty, preserveValue, false); /* delete that object in the transaction */ assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpDelete(deleterTxLocation + "/" + objId))); /* fetch the object-deleted-in-tx outside of the tx */ assertEquals("Expected to find our object outside the scope of the tx," + " despite it being deleted in the uncommitted transaction.", OK.getStatusCode(), getStatus(new HttpGet(serverAddress + objId))); /* mark the object as not deletable outside the context of the transaction */ setProperty(objId, preserveProperty, preserveValue); /* commit that transaction */ assertNotEquals("Transaction is not atomic with regards to the object!", NO_CONTENT.getStatusCode(), getStatus(new HttpPost(deleterTxLocation + "/fcr:tx/fcr:commit"))); } private void verifyProperty(final String assertionMessage, final String pid, final String txId, final String propertyUri, final String propertyValue, final boolean shouldExist) throws IOException { final HttpGet getObjCommitted = new HttpGet(serverAddress + (txId != null ? txId + "/" : "") + pid); try (final CloseableDataset dataset = getDataset(getObjCommitted)) { final boolean exists = dataset.asDatasetGraph().contains(ANY, createURI(serverAddress + pid), createURI(propertyUri), createLiteral(propertyValue)); if (shouldExist) { assertTrue(assertionMessage, exists); } else { assertFalse(assertionMessage, exists); } } } }