001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.integration.http.api;
019
020import static java.lang.Math.min;
021import static java.lang.Thread.sleep;
022import static java.nio.charset.StandardCharsets.UTF_8;
023import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL;
024import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
025import static javax.ws.rs.core.HttpHeaders.LINK;
026import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
027import static javax.ws.rs.core.Response.Status.CONFLICT;
028import static javax.ws.rs.core.Response.Status.CREATED;
029import static javax.ws.rs.core.Response.Status.GONE;
030import static javax.ws.rs.core.Response.Status.NOT_FOUND;
031import static javax.ws.rs.core.Response.Status.NO_CONTENT;
032import static javax.ws.rs.core.Response.Status.OK;
033import static javax.ws.rs.core.Response.Status.PRECONDITION_FAILED;
034import static org.apache.http.util.EntityUtils.consume;
035import static org.apache.jena.graph.Node.ANY;
036import static org.apache.jena.graph.NodeFactory.createLiteral;
037import static org.apache.jena.graph.NodeFactory.createURI;
038import static org.apache.jena.vocabulary.DC_11.title;
039import static org.fcrepo.http.commons.session.TransactionConstants.ATOMIC_EXPIRES_HEADER;
040import static org.fcrepo.http.commons.session.TransactionConstants.ATOMIC_ID_HEADER;
041import static org.fcrepo.http.commons.session.TransactionConstants.EXPIRES_RFC_1123_FORMATTER;
042import static org.fcrepo.http.commons.session.TransactionConstants.TX_COMMIT_REL;
043import static org.fcrepo.http.commons.session.TransactionConstants.TX_ENDPOINT_REL;
044import static org.fcrepo.http.commons.session.TransactionConstants.TX_PREFIX;
045import static org.fcrepo.kernel.api.FedoraTypes.FCR_TOMBSTONE;
046import static org.fcrepo.kernel.api.RdfLexicon.ARCHIVAL_GROUP;
047import static org.junit.Assert.assertEquals;
048import static org.junit.Assert.assertFalse;
049import static org.junit.Assert.assertNotNull;
050import static org.junit.Assert.assertTrue;
051import static org.junit.Assert.fail;
052import static org.mockito.Mockito.mock;
053import static org.mockito.Mockito.when;
054
055import java.io.IOException;
056import java.io.UncheckedIOException;
057import java.nio.file.Files;
058import java.time.Duration;
059import java.time.format.DateTimeParseException;
060import java.util.UUID;
061import java.util.regex.Matcher;
062import java.util.regex.Pattern;
063
064import javax.sql.DataSource;
065import javax.ws.rs.core.Response.Status;
066
067import org.fcrepo.common.lang.CheckedRunnable;
068import org.fcrepo.config.OcflPropsConfig;
069import org.fcrepo.http.commons.test.util.CloseableDataset;
070import org.fcrepo.kernel.api.ContainmentIndex;
071import org.fcrepo.kernel.api.Transaction;
072import org.fcrepo.kernel.api.identifiers.FedoraId;
073import org.fcrepo.storage.ocfl.CommitType;
074import org.fcrepo.storage.ocfl.DefaultOcflObjectSessionFactory;
075
076import org.apache.commons.lang3.StringUtils;
077import org.apache.http.Header;
078import org.apache.http.HttpEntity;
079import org.apache.http.client.HttpResponseException;
080import org.apache.http.client.methods.CloseableHttpResponse;
081import org.apache.http.client.methods.HttpDelete;
082import org.apache.http.client.methods.HttpGet;
083import org.apache.http.client.methods.HttpHead;
084import org.apache.http.client.methods.HttpPatch;
085import org.apache.http.client.methods.HttpPost;
086import org.apache.http.client.methods.HttpPut;
087import org.apache.http.entity.StringEntity;
088import org.apache.http.util.EntityUtils;
089import org.junit.After;
090import org.junit.Before;
091import org.junit.Test;
092import org.springframework.jdbc.core.JdbcTemplate;
093import org.springframework.test.context.TestExecutionListeners;
094
095/**
096 * <p>TransactionsIT class.</p>
097 *
098 * @author awoods
099 */
100@TestExecutionListeners(
101        listeners = { TestIsolationExecutionListener.class },
102        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
103public class TransactionsIT extends AbstractResourceIT {
104
105    public static final long REAP_INTERVAL = 1000;
106
107    public static final Pattern TX_ID_PATTERN = Pattern.compile(".+/" + TX_PREFIX + "([0-9a-f\\-]+)$");
108
109    private static final String ARCHIVAL_GROUP_TYPE = "<" + ARCHIVAL_GROUP + ">;rel=\"type\"";
110
111    private DefaultOcflObjectSessionFactory objectSessionFactory;
112    private ContainmentIndex containmentIndex;
113    private OcflPropsConfig ocflConfig;
114    private JdbcTemplate jdbcTemplate;
115
116    @Before
117    public void setup() {
118        objectSessionFactory = getBean(DefaultOcflObjectSessionFactory.class);
119        containmentIndex = getBean("containmentIndex", ContainmentIndex.class);
120        ocflConfig = getBean(OcflPropsConfig.class);
121        final var dataSource = getBean(DataSource.class);
122        jdbcTemplate = new JdbcTemplate(dataSource);
123    }
124
125    @After
126    public void after() {
127        objectSessionFactory.setDefaultCommitType(CommitType.NEW_VERSION);
128    }
129
130    private void dropContainment() {
131        jdbcTemplate.execute("DROP TABLE containment");
132    }
133
134    @Test
135    public void testRootHasTxEndpoint() throws Exception {
136        final var getRoot = new HttpGet(serverAddress);
137        try (final CloseableHttpResponse response = execute(getRoot)) {
138            final String txEndpointUri = serverAddress + TX_PREFIX;
139            checkForLinkHeader(response, txEndpointUri, TX_ENDPOINT_REL);
140        }
141    }
142
143    @Test
144    public void testCreateTransaction() throws IOException {
145        final HttpPost createTx = new HttpPost(serverAddress + "fcr:tx");
146        try (final CloseableHttpResponse response = execute(createTx)) {
147            assertEquals(CREATED.getStatusCode(), getStatus(response));
148            final var location = getLocation(response);
149            String txId = null;
150            final Matcher txMatcher = TX_ID_PATTERN.matcher(location);
151            if (txMatcher.matches()) {
152                txId = txMatcher.group(1);
153            }
154
155            assertNotNull("Expected Location header to send us to root node path within the transaction",
156                    txId);
157
158            final String commitUri = serverAddress + TX_PREFIX + txId;
159            checkForLinkHeader(response, commitUri, TX_COMMIT_REL);
160
161            assertHeaderIsRfc1123Date(response, "Expires");
162        }
163    }
164
165    @Test
166    public void testRequestsInTransactionThatDoestExist() {
167        /* create a tx */
168        assertEquals(NOT_FOUND.getStatusCode(), getStatus(new HttpPost(serverAddress + "fcr:tx/123idontexist")));
169    }
170
171    @Test
172    public void testCreateAndTimeoutTransaction() throws IOException, InterruptedException {
173
174        /* create a short-lived tx */
175        final long testTimeout = min(500, REAP_INTERVAL / 2);
176        propsConfig.setSessionTimeout(Duration.ofMillis(testTimeout));
177
178        /* create a tx */
179        final String location = createTransaction();
180
181        try (final CloseableHttpResponse resp = execute(new HttpGet(location))) {
182            assertEquals(Status.NO_CONTENT.getStatusCode(), getStatus(resp));
183            assertHeaderIsRfc1123Date(resp, ATOMIC_EXPIRES_HEADER);
184            consume(resp.getEntity());
185        }
186
187        sleep(REAP_INTERVAL * 2);
188        try {
189            assertEquals("Transaction did not expire", GONE.getStatusCode(), getStatus(new HttpGet(location)));
190        } finally {
191            propsConfig.setSessionTimeout(Duration.ofMillis(180000));
192        }
193    }
194
195    private void assertHeaderIsRfc1123Date(final CloseableHttpResponse response, final String headerName) {
196        final Header header = response.getFirstHeader(headerName);
197        assertNotNull("Header " + headerName + " was not set", header);
198        try {
199            EXPIRES_RFC_1123_FORMATTER.parse(header.getValue());
200        } catch (final DateTimeParseException e) {
201            fail("Expected header " + headerName + " to be an RFC1123 date, but was " + header.getValue());
202        }
203    }
204
205    @Test
206    public void testCreateDoStuffAndRollbackTransaction() throws IOException {
207        /* create a tx */
208        final String txLocation = createTransaction();
209
210        /* create a new object inside the tx */
211        final String newLocation;
212        final HttpPost postNew = new HttpPost(serverAddress);
213        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
214        try (final CloseableHttpResponse resp = execute(postNew)) {
215            assertEquals(CREATED.getStatusCode(), getStatus(resp));
216            newLocation = getLocation(resp);
217        }
218
219        /* fetch the created tx from the endpoint */
220        try (final CloseableDataset dataset = getDataset(addTxTo(new HttpGet(newLocation), txLocation))) {
221            assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(newLocation), ANY, ANY));
222        }
223        /* fetch the created tx from the endpoint */
224        assertEquals("Expected to not find our object within the scope of the transaction",
225                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newLocation)));
226
227        /* and rollback */
228        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpDelete(txLocation)));
229
230        assertEquals("Rolled back transaction should be gone",
231                GONE.getStatusCode(), getStatus(new HttpGet(txLocation)));
232
233        assertEquals("Expected to not find our object after rollback",
234                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newLocation)));
235
236        assertEquals("Expected to not find our object in transaction after rollback",
237                CONFLICT.getStatusCode(), getStatus(addTxTo(new HttpGet(newLocation), txLocation)));
238    }
239
240    @Test
241    public void rejectPutWhenIfMatchDoesNotMatch() throws IOException {
242        final String txLocation = createTransaction();
243
244        final String newLocation;
245        final HttpPost postNew = new HttpPost(serverAddress);
246        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
247        try (final CloseableHttpResponse resp = execute(postNew)) {
248            assertEquals(CREATED.getStatusCode(), getStatus(resp));
249            newLocation = getLocation(resp);
250        }
251
252        final var request = new HttpPut(newLocation);
253        request.addHeader(ATOMIC_ID_HEADER, txLocation);
254        request.addHeader("If-Match", "\"doesnt-match\"");
255        assertEquals(PRECONDITION_FAILED.getStatusCode(), getStatus(request));
256    }
257
258    @Test
259    public void allowPutWhenIfMatchMatches() throws IOException {
260        final String txLocation = createTransaction();
261
262        final String newLocation;
263        final String etag;
264        final HttpPost postNew = new HttpPost(serverAddress);
265        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
266        try (final CloseableHttpResponse resp = execute(postNew)) {
267            assertEquals(CREATED.getStatusCode(), getStatus(resp));
268            newLocation = getLocation(resp);
269            etag = resp.getFirstHeader("ETag").getValue();
270        }
271
272        final var request = new HttpPut(newLocation);
273        request.addHeader(ATOMIC_ID_HEADER, txLocation);
274        request.addHeader("If-Match", etag.substring(2));
275        assertEquals(NO_CONTENT.getStatusCode(), getStatus(request));
276    }
277
278    @Test
279    public void testRollbackShouldNotLeaveDbInPartiallyUpdatedState() throws IOException {
280        /* create a tx */
281        final String txLocation = createTransaction();
282
283        /* create a new object inside the tx */
284        final String newLocation;
285        final HttpPost postNew = new HttpPost(serverAddress);
286        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
287        try (final CloseableHttpResponse resp = execute(postNew)) {
288            assertEquals(CREATED.getStatusCode(), getStatus(resp));
289            newLocation = getLocation(resp);
290        }
291
292        final var resourceId = StringUtils.substringAfterLast(newLocation, "/");
293
294        /* fetch the created tx from the endpoint */
295        try (final CloseableDataset dataset = getDataset(addTxTo(new HttpGet(newLocation), txLocation))) {
296            assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(newLocation), ANY, ANY));
297        }
298        /* fetch the created tx from the endpoint */
299        assertEquals("Expected to not find our object within the scope of the transaction",
300                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newLocation)));
301
302        // Drop the entire containment table in order to cause an error.
303        dropContainment();
304
305        // Commit transaction -- should fail
306        assertEquals(CONFLICT.getStatusCode(), getStatus(new HttpPut(txLocation)));
307
308        assertEquals("Rolled back transaction should be gone",
309                GONE.getStatusCode(), getStatus(new HttpGet(txLocation)));
310
311        assertEquals("Expected to not find our object after rollback",
312                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newLocation)));
313
314        assertObjectDoesNotExistOnDisk(FedoraId.create(resourceId));
315    }
316
317    @Test
318    public void conflictingContainmentOverwritesObjectModifiedInTransactionRdf() throws IOException {
319        /* create a tx */
320        final String txLocation = createTransaction();
321
322        /* create a new object inside the tx */
323        final String newLocation;
324        final HttpPost postNew = new HttpPost(serverAddress);
325        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
326        try (final CloseableHttpResponse resp = execute(postNew)) {
327            assertEquals(CREATED.getStatusCode(), getStatus(resp));
328            newLocation = getLocation(resp);
329        }
330
331        final var resourceId = StringUtils.substringAfterLast(newLocation, "/");
332
333        /* fetch the created tx from the endpoint */
334        try (final CloseableDataset dataset = getDataset(addTxTo(new HttpGet(newLocation), txLocation))) {
335            assertTrue(dataset.asDatasetGraph().contains(ANY, createURI(newLocation), ANY, ANY));
336        }
337        /* fetch the created tx from the endpoint */
338        assertEquals("Expected to not find our object within the scope of the transaction",
339                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(newLocation)));
340
341        addConflictingContainmentRecord(resourceId);
342
343        // Commit transaction -- doesn't fail as the conflicting record overwrites the old one.
344        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
345
346        assertEquals("Committed transaction should be gone",
347                GONE.getStatusCode(), getStatus(new HttpGet(txLocation)));
348
349        assertEquals("Expected to not find our object after rollback",
350                OK.getStatusCode(), getStatus(new HttpGet(newLocation)));
351    }
352
353    @Test
354    public void conflictingContainmentOverwritesObjectModifiedInTransactionBinary() throws IOException {
355        final String txLocation1 = createTransaction();
356
357        final var bin1 = UUID.randomUUID().toString();
358        final var bin2 = UUID.randomUUID().toString();
359
360        putBinary(bin1, txLocation1, "test 1");
361
362        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation1)));
363
364        assertBinaryContent("test 1", bin1, null);
365
366        final String txLocation2 = createTransaction();
367
368        putBinary(bin1, txLocation2, "test 1 -- updated!");
369        putBinary(bin2, txLocation2, "test 2 -- I'm new!");
370
371        assertBinaryContent("test 1 -- updated!", bin1, txLocation2);
372        assertBinaryContent("test 2 -- I'm new!", bin2, txLocation2);
373
374        addConflictingContainmentRecord(bin2);
375
376        // Commit transaction
377        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation2)));
378
379        assertEquals("Committed transaction should be gone",
380                GONE.getStatusCode(), getStatus(new HttpGet(txLocation2)));
381
382        assertBinaryContent("test 1 -- updated!", bin1, null);
383        assertBinaryContent("test 2 -- I'm new!", bin2, null);
384    }
385
386    @Test
387    public void rollbackFailsWhenAutoVersioningNotUsedAndFailureInOcflCommit() throws IOException {
388        objectSessionFactory.setDefaultCommitType(CommitType.UNVERSIONED);
389
390        final String txLocation1 = createTransaction();
391
392        // need prefix so they're ordered deterministically
393        final var bin1 = "1" + UUID.randomUUID().toString();
394        final var bin2 = "2" + UUID.randomUUID().toString();
395        final var bin3 = "3" + UUID.randomUUID().toString();
396
397        putBinary(bin1, txLocation1, "test 1");
398        putBinary(bin3, txLocation1, "test 3");
399
400        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation1)));
401
402        assertBinaryContent("test 1", bin1, null);
403        assertBinaryContent("test 3", bin3, null);
404
405        final String txLocation2 = createTransaction();
406
407        putBinary(bin1, txLocation2, "test 1 -- updated!");
408        putBinary(bin2, txLocation2, "test 2 -- I'm new!");
409        putBinary(bin3, txLocation2, "test 3 -- updated!");
410
411        assertBinaryContent("test 1 -- updated!", bin1, txLocation2);
412        assertBinaryContent("test 2 -- I'm new!", bin2, txLocation2);
413
414        corruptStagedBinary(bin3);
415
416        // Commit transaction -- should fail
417        assertEquals(CONFLICT.getStatusCode(), getStatus(new HttpPut(txLocation2)));
418
419        assertEquals("Rolled back transaction should be gone",
420                GONE.getStatusCode(), getStatus(new HttpGet(txLocation2)));
421
422        // bin1 was not rolled back
423        assertBinaryContent("test 1 -- updated!", bin1, null);
424
425        // bin2 was rolled back
426        assertEquals("Expected to not find our object after rollback",
427                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(serverAddress + bin2)));
428        assertObjectDoesNotExistOnDisk(FedoraId.create(bin2));
429
430        // bin1 was not committed
431        assertBinaryContent("test 3", bin3, null);
432    }
433
434    @Test
435    public void rollbackSucceedsWhenAutoVersioningUsedAndFailureInOcflCommit() throws IOException {
436        final String txLocation1 = createTransaction();
437
438        // need prefix so they're ordered deterministically
439        final var bin1 = "1" + UUID.randomUUID().toString();
440        final var bin2 = "2" + UUID.randomUUID().toString();
441        final var bin3 = "3" + UUID.randomUUID().toString();
442
443        putBinary(bin1, txLocation1, "test 1");
444        putBinary(bin3, txLocation1, "test 3");
445
446        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation1)));
447
448        assertBinaryContent("test 1", bin1, null);
449        assertBinaryContent("test 3", bin3, null);
450
451        final String txLocation2 = createTransaction();
452
453        putBinary(bin1, txLocation2, "test 1 -- updated!");
454        putBinary(bin2, txLocation2, "test 2 -- I'm new!");
455        putBinary(bin3, txLocation2, "test 3 -- updated!");
456
457        assertBinaryContent("test 1 -- updated!", bin1, txLocation2);
458        assertBinaryContent("test 2 -- I'm new!", bin2, txLocation2);
459
460        corruptStagedBinary(bin3);
461
462        // Commit transaction -- should fail
463        assertEquals(CONFLICT.getStatusCode(), getStatus(new HttpPut(txLocation2)));
464
465        assertEquals("Rolled back transaction should be gone",
466                GONE.getStatusCode(), getStatus(new HttpGet(txLocation2)));
467
468        // bin1 was rolled back
469        assertBinaryContent("test 1", bin1, null);
470
471        // bin2 was rolled back
472        assertEquals("Expected to not find our object after rollback",
473                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(serverAddress + bin2)));
474        assertObjectDoesNotExistOnDisk(FedoraId.create(bin2));
475
476        // bin1 was not committed
477        assertBinaryContent("test 3", bin3, null);
478    }
479
480    @Test
481    public void testTransactionKeepAlive() throws IOException {
482        /* create a tx */
483        final String txLocation = createTransaction();
484        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPost(txLocation)));
485    }
486
487    @Test
488    public void testCreateDoStuffAndCommitTransaction() throws IOException {
489        /* create a tx */
490        final String txLocation = createTransaction();
491        /* create a new object inside the tx */
492        final HttpPost postNew = new HttpPost(serverAddress);
493        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
494
495        final String datasetLoc;
496        try (final CloseableHttpResponse resp = execute(postNew)) {
497            assertEquals(CREATED.getStatusCode(), resp.getStatusLine().getStatusCode());
498            assertHasAtomicId(txLocation, resp);
499            datasetLoc = getLocation(resp);
500        }
501
502        // Retrieve the object inside of the transaction
503        final HttpGet getRequest = new HttpGet(datasetLoc);
504        getRequest.addHeader(ATOMIC_ID_HEADER, txLocation);
505        try (final CloseableDataset dataset = getDataset(getRequest)) {
506            assertTrue(dataset.asDatasetGraph().contains(ANY,
507                        createURI(datasetLoc), ANY, ANY));
508        }
509
510        /* fetch the object-in-tx outside of the tx */
511        assertEquals("Expected to not find our object within the scope of the transaction",
512                NOT_FOUND.getStatusCode(), getStatus(new HttpGet(datasetLoc)));
513        /* and commit */
514        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
515
516        /* fetch the object-in-tx outside of the tx after it has been committed */
517        try (final CloseableDataset dataset = getDataset(new HttpGet(datasetLoc))) {
518            assertTrue("Expected to  find our object after the transaction was committed",
519                    dataset.asDatasetGraph().contains(ANY, createURI(datasetLoc), ANY, ANY));
520        }
521
522        assertEquals("Expect conflict when trying to retrieve from committed transaction",
523                CONFLICT.getStatusCode(), getStatus(addTxTo(new HttpGet(datasetLoc), txLocation)));
524    }
525
526    @Test
527    public void transactionShouldNotBeAbleToBeCommittedWhenARequestFails() throws IOException {
528        final var agId = getRandomUniqueId();
529        final var childId = agId + "/child";
530
531        // create a tx
532        final String txLocation = createTransaction();
533
534        putAg(agId, txLocation);
535        getResource(agId, txLocation);
536
537        var put = putObjMethod(childId);
538        put.addHeader(ATOMIC_ID_HEADER, txLocation);
539        put.setHeader("Link", ARCHIVAL_GROUP_TYPE);
540        try (final CloseableHttpResponse response = execute(put)) {
541            assertEquals(CONFLICT.getStatusCode(), getStatus(response));
542        }
543
544        // subsequent request should fail
545        put = putObjMethod(childId);
546        put.addHeader(ATOMIC_ID_HEADER, txLocation);
547        try (final CloseableHttpResponse response = execute(put)) {
548            assertEquals(CONFLICT.getStatusCode(), getStatus(response));
549        }
550
551        // and commit should fail
552        assertEquals(CONFLICT.getStatusCode(), getStatus(new HttpPut(txLocation)));
553
554        // ag should not exist
555        try (final CloseableHttpResponse response = execute(new HttpGet(serverAddress + agId))) {
556            assertEquals(NOT_FOUND.getStatusCode(), response.getStatusLine().getStatusCode());
557        }
558    }
559
560    private void assertHasAtomicId(final String txId, final CloseableHttpResponse resp) {
561        final Header header = resp.getFirstHeader(ATOMIC_ID_HEADER);
562        assertNotNull("No atomic id header present in response", header);
563
564        assertEquals("Header did not match the expected atomic id", txId, header.getValue());
565    }
566
567    /**
568     * Tests whether a Sparql update is visible within a transaction and if the update is made persistent along with
569     * the commit.
570     *
571     * @throws IOException exception thrown during this function
572     */
573    @Test
574    public void testIngestNewWithSparqlPatchWithinTransaction() throws IOException {
575        /* create new tx */
576        final String txLocation = createTransaction();
577
578        final HttpPost postNew = new HttpPost(serverAddress);
579        final String newObjectLocation;
580        try (final CloseableHttpResponse resp = execute(postNew)) {
581            assertEquals(CREATED.getStatusCode(), getStatus(resp));
582            newObjectLocation = getLocation(resp);
583        }
584
585        /* update sparql */
586        final HttpPatch method = addTxTo(new HttpPatch(newObjectLocation), txLocation);
587        method.addHeader(CONTENT_TYPE, "application/sparql-update");
588        final String newTitle = "this is a new title";
589        method.setEntity(new StringEntity("INSERT { <> <http://purl.org/dc/elements/1.1/title> \"" + newTitle +
590                "\" } WHERE {}"));
591        assertEquals("Didn't get a NO CONTENT status!", NO_CONTENT.getStatusCode(), getStatus(method));
592
593        /* make sure the change was made within the tx */
594        try (final CloseableDataset dataset = getDataset(addTxTo(new HttpGet(newObjectLocation), txLocation))) {
595            assertTrue("The sparql update did not succeed within a transaction", dataset.asDatasetGraph().contains(ANY,
596                    createURI(newObjectLocation), title.asNode(), createLiteral(newTitle)));
597        }
598
599        // Verify that the change is not visible outside the TX
600        try (final CloseableDataset dataset = getDataset(new HttpGet(newObjectLocation))) {
601            assertFalse("Sparql update changes must not be visible out of tx", dataset.asDatasetGraph().contains(ANY,
602                    createURI(newObjectLocation), title.asNode(), createLiteral(newTitle)));
603        }
604
605        /* commit */
606        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
607
608        /* it must exist after commit */
609        try (final CloseableDataset dataset = getDataset(new HttpGet(newObjectLocation))) {
610            assertTrue("The inserted triple does not exist after the transaction has committed",
611                    dataset.asDatasetGraph().contains(ANY, ANY, title.asNode(), createLiteral(newTitle)));
612        }
613    }
614
615    @Test
616    public void testGetNonExistingObject() throws IOException {
617        final String txLocation = createTransaction();
618        final String newObjectLocation = serverAddress + "idontexist";
619        assertEquals("Status should be NOT FOUND", NOT_FOUND.getStatusCode(),
620                getStatus(addTxTo(new HttpGet(newObjectLocation), txLocation)));
621    }
622
623    /*
624     *  Caching headers should now be present during transactions. They were not in previous modeshape based versions.
625     */
626    @Test
627    public void testCachingHeadersDuringTransaction() throws IOException {
628        final String txLocation = createTransaction();
629        final String location;
630        try (final CloseableHttpResponse resp = execute(addTxTo(new HttpPost(serverAddress), txLocation))) {
631            assertTrue("Last-Modified must be present during a transaction", resp.containsHeader("Last-Modified"));
632            assertTrue("ETag must be present during a transaction", resp.containsHeader("ETag"));
633            assertTrue("Expected an X-State-Token header", resp.getHeaders("X-State-Token").length > 0);
634            // Assert Cache-Control headers are present to invalidate caches
635            location = getLocation(resp);
636        }
637        try (final CloseableHttpResponse resp = execute(addTxTo(new HttpGet(location), txLocation))) {
638            assertTrue("Last-Modified must be present during a transaction", resp.containsHeader("Last-Modified"));
639            assertTrue("ETag must be present during a transaction", resp.containsHeader("ETag"));
640            assertTrue("Expected an X-State-Token header", resp.getHeaders("X-State-Token").length > 0);
641            final Header[] headers = resp.getHeaders(CACHE_CONTROL);
642            assertEquals("Two cache control headers expected: ", 2, headers.length);
643            assertEquals("must-revalidate expected", "must-revalidate", headers[0].getValue());
644            assertEquals("max-age=0 expected", "max-age=0", headers[1].getValue());
645            consume(resp.getEntity());
646        }
647    }
648
649    /**
650     * Test for issue https://jira.duraspace.org/browse/FCREPO-2975
651     * @throws java.lang.Exception exception thrown during this function
652     */
653    @Test
654    public void testHeadAndDeleteInTransaction() throws Exception {
655        final String id = getRandomUniqueId();
656        createObject(id);
657        final String objUri = serverAddress + "/" + id;
658
659        try (final CloseableHttpResponse resp = execute(new HttpHead(objUri))) {
660            assertEquals(OK.getStatusCode(), resp.getStatusLine().getStatusCode());
661        }
662
663        final String txLocation = createTransaction();
664
665        // Make a head request against the object within the transaction
666        try (final CloseableHttpResponse resp = execute(addTxTo(new HttpHead(objUri), txLocation))) {
667            assertEquals(OK.getStatusCode(), resp.getStatusLine().getStatusCode());
668        }
669
670        // Delete the binary within the transaction
671        try (final CloseableHttpResponse resp = execute(addTxTo(new HttpDelete(objUri), txLocation))) {
672            assertEquals(NO_CONTENT.getStatusCode(), resp.getStatusLine().getStatusCode());
673        }
674
675        // Commit the transaction containing deletion
676        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
677    }
678
679    /**
680     * Tests that transactions are treated as atomic with regards to nodes. A common use case for applications written
681     * against fedora is that an operation checks some property of a fedora object and acts on it accordingly. In
682     * order for this to work in a multi-client or multi-threaded environment that comparison+action combination needs
683     * to be atomic. Imagine a scenario where we have one process that deletes all objects in the repository that
684     * don't have a "preserve" property set to the literal "true", and we have any number of other clients that add
685     * such a property. We want to ensure that there is no way for a client to successfully add this property between
686     * when the "deleter" process has determined that no such property exists and when it deletes the object. In other
687     * words, if there are only clients adding properties and the "deleter" deleting objects it should not be possible
688     * for an object to be deleted if a client has added a title and received a successful http response code.
689     *
690     * @throws IOException exception thrown during this function
691     */
692    @Test
693    public void testTransactionAndConcurrentConflictingUpdate() throws IOException {
694        final String preserveProperty = "preserve";
695        final String preserveValue = "true";
696
697        /* create the object in question */
698        final String objectLocation;
699        try (final var response = execute(new HttpPost(serverAddress))) {
700            assertEquals(CREATED.getStatusCode(), getStatus(response));
701            objectLocation = getLocation(response);
702        }
703
704         /* create the deleter transaction */
705        final String deleterTxLocation = createTransaction();
706
707        /* assert that the object is eligible for delete in the transaction */
708        verifyProperty("No preserve property should be set!", objectLocation, deleterTxLocation, preserveProperty,
709                preserveValue, false);
710
711        /* delete that object in the transaction */
712        final var delete = new HttpDelete(objectLocation);
713        addTxTo(delete, deleterTxLocation);
714        assertEquals(NO_CONTENT.getStatusCode(), getStatus(delete));
715
716        /* fetch the object-deleted-in-tx outside of the tx */
717        assertEquals("Expected to find our object outside the scope of the tx,"
718                + " despite it being deleted in the uncommitted transaction.",
719                OK.getStatusCode(), getStatus(new HttpGet(objectLocation)));
720
721        /* Try to mark the object as not deletable outside the context of the transaction */
722        final HttpPatch postProp = new HttpPatch(objectLocation);
723        postProp.setHeader(CONTENT_TYPE, "application/sparql-update");
724        final String updateString =
725                "INSERT { <" + objectLocation +
726                        "> <" + preserveProperty + "> " + preserveValue + " } WHERE { }";
727        postProp.setEntity(new StringEntity(updateString, UTF_8));
728        assertEquals(CONFLICT.getStatusCode(), getStatus(postProp));
729
730        /* commit that transaction */
731        assertEquals("Transaction is still atomic with regards to the object!",
732                NO_CONTENT.getStatusCode(), getStatus(new HttpPut(deleterTxLocation)));
733    }
734
735    @Test
736    public void testRequestResourceInvalidTx() throws Exception {
737        /* create a tx */
738        final String txLocation = createTransaction();
739
740        // Commit tx
741        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
742
743        // Attempt to create object inside completed tx
744        final HttpPost postNew = new HttpPost(serverAddress);
745        postNew.addHeader(ATOMIC_ID_HEADER, txLocation);
746        assertEquals(Status.CONFLICT.getStatusCode(), getStatus(postNew));
747    }
748
749    @Test
750    public void testRequestWithBareTxUuid() throws Exception {
751        final String txLocation = createTransaction();
752        final String uuid = txLocation.substring(txLocation.lastIndexOf("/") + 1);
753
754        final String newLocation;
755        // Attempt to create object in tx using just the uuid
756        final HttpPost postNew = addTxTo(new HttpPost(serverAddress), uuid);
757        try (final CloseableHttpResponse resp = execute(postNew)) {
758            assertEquals(CREATED.getStatusCode(), getStatus(resp));
759            newLocation = getLocation(resp);
760        }
761
762        // Retrieve in tx using uuid
763        assertEquals(OK.getStatusCode(), getStatus(addTxTo(new HttpGet(newLocation), uuid)));
764
765        // Commit tx
766        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
767
768        // Retrieve outside of tx
769        assertEquals(OK.getStatusCode(), getStatus(new HttpGet(newLocation)));
770    }
771
772    @Test
773    public void testRequestWitMadeUpTxUuid() throws Exception {
774        // Attempt to create object inside completed tx
775        final HttpPost postNew = new HttpPost(serverAddress);
776        postNew.addHeader(ATOMIC_ID_HEADER, UUID.randomUUID().toString());
777        assertEquals(Status.CONFLICT.getStatusCode(), getStatus(postNew));
778    }
779
780    /**
781     * Test creating and deleting an object in a single transaction.
782     * @throws Exception http client might throw an exception.
783     */
784    @Test
785    public void testCreateAndDeleteInSingleTransaction() throws Exception {
786        // Do an RDF Container
787        final String txLocation = createTransaction();
788        final HttpPost post = postObjMethod();
789        addTxTo(post, txLocation);
790        final String containerUri;
791        try (final CloseableHttpResponse response = execute(post)) {
792            assertEquals(CREATED.getStatusCode(), getStatus(response));
793            containerUri = getLocation(response);
794        }
795        final HttpDelete delete = new HttpDelete(containerUri);
796        addTxTo(delete, txLocation);
797        assertEquals(NO_CONTENT.getStatusCode(), getStatus(delete));
798
799        // Container was never committed, so we get a 404 instead of 410.
800        final HttpGet getContainer = new HttpGet(containerUri);
801        addTxTo(getContainer, txLocation);
802        assertEquals(NOT_FOUND.getStatusCode(), getStatus(getContainer));
803
804        // Now do a binary
805        final HttpPost postBin = postObjMethod();
806        addTxTo(postBin, txLocation);
807        postBin.setEntity(new StringEntity("Some test text"));
808        final String binaryUri;
809        try (final CloseableHttpResponse response = execute(postBin)) {
810            assertEquals(CREATED.getStatusCode(), getStatus(response));
811            binaryUri = getLocation(response);
812        }
813        final HttpDelete deleteBin = new HttpDelete(binaryUri);
814        addTxTo(deleteBin, txLocation);
815        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteBin));
816
817        // Container was never committed, so we get a 404 instead of 410.
818        final HttpGet getBinary = new HttpGet(binaryUri);
819        addTxTo(getBinary, txLocation);
820        assertEquals(NOT_FOUND.getStatusCode(), getStatus(getBinary));
821    }
822
823    /**
824     * Test creating an AG and child container in a transaction and deleting the child container.
825     * @throws Exception http client might throw an exception.
826     */
827    @Test
828    public void testCreateAndDeleteInSingleTransactionSubPathFull() throws Exception {
829        final String txLocation = createTransaction();
830        // Create an Archival Group.
831        final HttpPost httpPost = postObjMethod();
832        addTxTo(httpPost, txLocation);
833        httpPost.setHeader("Link", ARCHIVAL_GROUP_TYPE);
834        final String parent;
835        try (final CloseableHttpResponse response = execute(httpPost)) {
836            assertEquals(CREATED.getStatusCode(), getStatus(response));
837            parent = getLocation(response);
838        }
839        // Test GET the parent.
840        final HttpGet parentGet = new HttpGet(parent);
841        addTxTo(parentGet, txLocation);
842        assertEquals(OK.getStatusCode(), getStatus(parentGet));
843
844        // Create a container child of the AG.
845        final HttpPost postChild = new HttpPost(parent);
846        addTxTo(postChild, txLocation);
847        final String id;
848        try (final CloseableHttpResponse response = execute(postChild)) {
849            assertEquals(CREATED.getStatusCode(), getStatus(response));
850            id = getLocation(response);
851        }
852        // Test GET the child.
853        final HttpGet childGetContainer = new HttpGet(id);
854        addTxTo(childGetContainer, txLocation);
855        assertEquals(OK.getStatusCode(), getStatus(childGetContainer));
856        // Delete the child container.
857        final HttpDelete deleteContainer = new HttpDelete(id);
858        addTxTo(deleteContainer, txLocation);
859        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteContainer));
860        // Test GET the child again.
861        final HttpGet childGetContainer2 = new HttpGet(id);
862        addTxTo(childGetContainer2, txLocation);
863        assertEquals(NOT_FOUND.getStatusCode(), getStatus(childGetContainer2));
864
865        // Create a binary child of the AG.
866        final HttpPost postBinary = new HttpPost(parent);
867        addTxTo(postBinary, txLocation);
868        final String binaryId;
869        try (final CloseableHttpResponse response = execute(postBinary)) {
870            assertEquals(CREATED.getStatusCode(), getStatus(response));
871            binaryId = getLocation(response);
872        }
873        // Test GET the child.
874        final HttpGet childGetBinary = new HttpGet(binaryId);
875        addTxTo(childGetBinary, txLocation);
876        assertEquals(OK.getStatusCode(), getStatus(childGetBinary));
877        // Delete the child container.
878        final HttpDelete deleteBinary = new HttpDelete(binaryId);
879        addTxTo(deleteBinary, txLocation);
880        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteBinary));
881        // Test GET the child again.
882        final HttpGet childGetBinary2 = new HttpGet(binaryId);
883        addTxTo(childGetBinary2, txLocation);
884        assertEquals(NOT_FOUND.getStatusCode(), getStatus(childGetBinary2));
885    }
886
887
888    /**
889     * Test creating an AG. Then create a child container and delete it in the same transaction.
890     * @throws Exception http client might throw an exception.
891     */
892    @Test
893    public void testCreateAndDeleteInSingleTransactionSubPathPartial() throws Exception {
894        // Create an Archival Group.
895        final HttpPost httpPost = postObjMethod();
896        httpPost.setHeader("Link", ARCHIVAL_GROUP_TYPE);
897        final String parent;
898        try (final CloseableHttpResponse response = execute(httpPost)) {
899            assertEquals(CREATED.getStatusCode(), getStatus(response));
900            parent = getLocation(response);
901        }
902        assertEquals(OK.getStatusCode(), getStatus(new HttpGet(parent)));
903
904        final String txLocation = createTransaction();
905        // Create a child RDF container.
906        final HttpPost postContainer = new HttpPost(parent);
907        addTxTo(postContainer, txLocation);
908        final String containerId;
909        try (final CloseableHttpResponse response = execute(postContainer)) {
910            assertEquals(CREATED.getStatusCode(), getStatus(response));
911            containerId = getLocation(response);
912        }
913        // Test GET the container.
914        final HttpGet getContainer = new HttpGet(containerId);
915        addTxTo(getContainer, txLocation);
916        assertEquals(OK.getStatusCode(), getStatus(getContainer));
917        // Delete the container.
918        final HttpDelete deleteContainer = new HttpDelete(containerId);
919        addTxTo(deleteContainer, txLocation);
920        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteContainer));
921        // Test GET the container again.
922        final HttpGet childGetContainer2 = new HttpGet(containerId);
923        addTxTo(childGetContainer2, txLocation);
924        assertEquals(NOT_FOUND.getStatusCode(), getStatus(childGetContainer2));
925
926        // Create a child binary.
927        final HttpPost postBinary = new HttpPost(parent);
928        addTxTo(postBinary, txLocation);
929        postBinary.setEntity(new StringEntity("Some test text"));
930        final String binaryId;
931        try (final CloseableHttpResponse response = execute(postBinary)) {
932            assertEquals(CREATED.getStatusCode(), getStatus(response));
933            binaryId = getLocation(response);
934        }
935        // Test GET the binary.
936        final HttpGet getBinary = new HttpGet(binaryId);
937        addTxTo(getBinary, txLocation);
938        assertEquals(OK.getStatusCode(), getStatus(getBinary));
939        // Delete the binary.
940        final HttpDelete deleteBinary = new HttpDelete(binaryId);
941        addTxTo(deleteBinary, txLocation);
942        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteBinary));
943        // Test GET the binary again.
944        final HttpGet childGetBinary2 = new HttpGet(binaryId);
945        addTxTo(childGetBinary2, txLocation);
946        assertEquals(NOT_FOUND.getStatusCode(), getStatus(childGetBinary2));
947    }
948
949    @Test
950    public void lockAgResourceInTxWhenAgPartIsUpdated() throws Exception {
951        final var agId = getRandomUniqueId();
952        final var childId = agId + "/child";
953        final var binaryId = agId + "/child/bin";
954
955        putAg(agId, null);
956        putContainer(childId, null);
957        putBinary(binaryId, null, "binary");
958
959        final String txLocation = createTransaction();
960
961        putBinary(binaryId, txLocation, "binary - updated!");
962
963        assertConcurrentUpdate(() -> putBinary(binaryId, null, "concurrent update!"));
964        assertConcurrentUpdate(() -> updateContainerTitle(childId, "concurrent update!", null));
965        assertConcurrentUpdate(() -> updateContainerTitle(agId, "concurrent update!", null));
966        assertConcurrentUpdate(() -> putContainer(agId + "/child2", null));
967
968        commitTransaction(txLocation);
969
970        assertEquals("binary - updated!", getResource(binaryId, null));
971
972        putBinary(binaryId, null, "unlocked!");
973        assertEquals("unlocked!", getResource(binaryId, null));
974    }
975
976    @Test
977    public void lockBothBinaryAndDescWhenEitherIsUpdatedInTx() throws Exception {
978        final var binaryId = getRandomUniqueId();
979
980        putBinary(binaryId, null, "binary");
981
982        final String txLocation = createTransaction();
983
984        putBinary(binaryId, txLocation, "binary - updated!");
985
986        assertConcurrentUpdate(() -> putBinary(binaryId, null, "concurrent update!"));
987        assertConcurrentUpdate(() -> updateContainerTitle(binaryId + "/fcr:metadata", "concurrent update!", null));
988
989        commitTransaction(txLocation);
990
991        assertEquals("binary - updated!", getResource(binaryId, null));
992    }
993
994    @Test
995    public void concurrentSparqlUpdatesShouldNotBeAllowed() throws Exception {
996        final var containerId = getRandomUniqueId();
997
998        putContainer(containerId, null);
999
1000        final String txLocation = createTransaction();
1001
1002        updateContainerTitle(containerId, "new title", txLocation);
1003
1004        assertConcurrentUpdate(() -> updateContainerTitle(containerId, "concurrent update!", null));
1005
1006        commitTransaction(txLocation);
1007
1008        final var response = getResource(containerId, null);
1009        assertTrue("title should have been updated", response.contains("new title"));
1010        assertFalse("concurrent update should not have been applied", response.contains("concurrent update!"));
1011    }
1012
1013    @Test
1014    public void testDeleteAndRecreateInTransaction() throws Exception {
1015        final String container;
1016        // Create a container
1017        try (final var response = execute(postObjMethod())) {
1018            assertEquals(CREATED.getStatusCode(), getStatus(response));
1019            container = getLocation(response);
1020        }
1021        final String txLocation = createTransaction();
1022        // Delete in a transaction
1023        final var deleteInTx = new HttpDelete(container);
1024        addTxTo(deleteInTx, txLocation);
1025        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteInTx));
1026        // Ensure it is gone in the transaction
1027        final var getInTx = new HttpDelete(container);
1028        addTxTo(getInTx, txLocation);
1029        assertEquals(GONE.getStatusCode(), getStatus(getInTx));
1030        // Delete the tombstone in the transaction
1031        final var deleteTombInTx = new HttpDelete(container + "/" + FCR_TOMBSTONE);
1032        addTxTo(deleteTombInTx, txLocation);
1033        assertEquals(NO_CONTENT.getStatusCode(), getStatus(deleteTombInTx));
1034        // Ensure the container is completely gone
1035        assertEquals(NOT_FOUND.getStatusCode(), getStatus(getInTx));
1036        // Inside the transaction create the same container
1037        final var putInTx = new HttpPut(container);
1038        addTxTo(putInTx, txLocation);
1039        assertEquals(CREATED.getStatusCode(), getStatus(putInTx));
1040        // Commit the transaction
1041        assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpPut(txLocation)));
1042    }
1043
1044    private void assertConcurrentUpdate(final CheckedRunnable runnable) throws Exception {
1045        try {
1046            runnable.run();
1047            fail("Request should fail because the resource should be locked by another transaction.");
1048        } catch (final HttpResponseException e) {
1049            assertEquals(CONFLICT.getStatusCode(), e.getStatusCode());
1050            assertTrue("concurrent update exception",
1051                    e.getReasonPhrase().contains("updated by another transaction"));
1052        }
1053    }
1054
1055    private void verifyProperty(final String assertionMessage, final String uri, final String txId,
1056            final String propertyUri, final String propertyValue, final boolean shouldExist) throws IOException {
1057        final HttpGet getObjCommitted = new HttpGet(uri);
1058        if (txId != null) {
1059            addTxTo(getObjCommitted, txId);
1060        }
1061        try (final CloseableDataset dataset = getDataset(getObjCommitted)) {
1062            final boolean exists = dataset.asDatasetGraph().contains(ANY,
1063                    createURI(serverAddress + uri), createURI(propertyUri), createLiteral(propertyValue));
1064            if (shouldExist) {
1065                assertTrue(assertionMessage, exists);
1066            } else {
1067                assertFalse(assertionMessage, exists);
1068            }
1069        }
1070    }
1071
1072    private void assertObjectDoesNotExistOnDisk(final FedoraId fedoraId) {
1073        assertFalse(String.format("Expected %s to not exist on disk", fedoraId), objectExistsOnDisk(fedoraId));
1074    }
1075
1076    private void assertObjectExistsOnDisk(final FedoraId fedoraId) {
1077        assertTrue(String.format("Expected %s to exist on disk", fedoraId), objectExistsOnDisk(fedoraId));
1078    }
1079
1080    private boolean objectExistsOnDisk(final FedoraId fedoraId) {
1081        try (final var session = objectSessionFactory.newSession(fedoraId.getResourceId())) {
1082            return session.containsResource(fedoraId.getResourceId());
1083        }
1084    }
1085
1086    private void assertBinaryContent(final String expected,
1087                                     final String id,
1088                                     final String txLocation) throws IOException {
1089        final var actual = getResource(id, txLocation);
1090        assertEquals("Expected binary content for " + id, expected, actual);
1091    }
1092
1093    private void putAg(final String id, final String txLocation) throws IOException {
1094        final var put = putObjMethod(id);
1095
1096        if (txLocation != null) {
1097            put.addHeader(ATOMIC_ID_HEADER, txLocation);
1098        }
1099
1100        put.setHeader("Link", ARCHIVAL_GROUP_TYPE);
1101        try (final CloseableHttpResponse response = execute(put)) {
1102            assertEquals(CREATED.getStatusCode(), getStatus(response));
1103        }
1104    }
1105
1106    private void putContainer(final String id, final String txLocation) throws IOException {
1107        final var put = putObjMethod(id);
1108        if (txLocation != null) {
1109            put.addHeader(ATOMIC_ID_HEADER, txLocation);
1110        }
1111        try (final CloseableHttpResponse resp = execute(put)) {
1112            final var code = getStatus(resp);
1113            if (code != CREATED.getStatusCode()) {
1114                throw new HttpResponseException(code, EntityUtils.toString(resp.getEntity()));
1115            }
1116        }
1117    }
1118
1119    private void updateContainerTitle(final String id, final String title, final String txLocation) throws IOException {
1120        final var patch = patchObjMethod(id);
1121        patch.addHeader(CONTENT_TYPE, "application/sparql-update");
1122        patch.setEntity(new StringEntity("INSERT { <> <http://purl.org/dc/elements/1.1/title> \"" + title +
1123                "\" } WHERE {}"));
1124        if (txLocation != null) {
1125            patch.addHeader(ATOMIC_ID_HEADER, txLocation);
1126        }
1127        try (final CloseableHttpResponse resp = execute(patch)) {
1128            final var code = getStatus(resp);
1129            if (code != NO_CONTENT.getStatusCode()) {
1130                throw new HttpResponseException(code, EntityUtils.toString(resp.getEntity()));
1131            }
1132        }
1133    }
1134
1135    private void putBinary(final String id, final String txLocation, final String content) throws IOException {
1136        final HttpPut put = new HttpPut(serverAddress + id);
1137        put.setEntity(new StringEntity(content == null ? "" : content));
1138        put.setHeader(CONTENT_TYPE, TEXT_PLAIN);
1139        put.setHeader(LINK, NON_RDF_SOURCE_LINK_HEADER);
1140        if (txLocation != null) {
1141            put.addHeader(ATOMIC_ID_HEADER, txLocation);
1142        }
1143        try (final CloseableHttpResponse resp = execute(put)) {
1144            final var code = getStatus(resp);
1145            if (code != CREATED.getStatusCode() && code != NO_CONTENT.getStatusCode()) {
1146                throw new HttpResponseException(code, EntityUtils.toString(resp.getEntity()));
1147            }
1148        }
1149    }
1150
1151    private String getResource(final String id, final String txLocation) throws IOException {
1152        final var get = new HttpGet(serverAddress + id);
1153        if (txLocation != null) {
1154            get.addHeader(ATOMIC_ID_HEADER, txLocation);
1155        }
1156        try (final CloseableHttpResponse response = execute(get)) {
1157            final HttpEntity entity = response.getEntity();
1158            final String content = EntityUtils.toString(entity);
1159            assertEquals(OK.getStatusCode(), response.getStatusLine().getStatusCode());
1160            return content;
1161        }
1162    }
1163
1164    private void commitTransaction(final String txLocation) throws IOException {
1165        try (final CloseableHttpResponse resp = execute(new HttpPut(txLocation))) {
1166            final var code = getStatus(resp);
1167            if (code != NO_CONTENT.getStatusCode()) {
1168                throw new HttpResponseException(code, EntityUtils.toString(resp.getEntity()));
1169            }
1170        }
1171    }
1172
1173    private void addConflictingContainmentRecord(final String resourceId) {
1174        final var txId = UUID.randomUUID().toString();
1175        final var tx = mock(Transaction.class);
1176        when(tx.getId()).thenReturn(txId);
1177        when(tx.isShortLived()).thenReturn(true);
1178        containmentIndex.addContainedBy(tx, FedoraId.getRepositoryRootId(), FedoraId.create(resourceId));
1179        containmentIndex.commitTransaction(tx);
1180    }
1181
1182    private void corruptStagedBinary(final String resourceId) {
1183        final var lastPart = resourceId.contains("/") ?
1184                StringUtils.substringAfterLast(resourceId, "/") : resourceId;
1185        final var stagingRoot = ocflConfig.getFedoraOcflStaging();
1186        try {
1187            final var binary = Files.find(stagingRoot, 10, (file, attrs) -> {
1188                return attrs.isRegularFile() &&
1189                        file.getFileName().toString().equals(lastPart);
1190            }).findFirst().get();
1191
1192            Files.writeString(binary, "corrupted!");
1193        } catch (final IOException e) {
1194            throw new UncheckedIOException(e);
1195        }
1196    }
1197
1198}