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.nio.charset.StandardCharsets.UTF_8;
021import static java.time.format.DateTimeFormatter.ISO_INSTANT;
022import static java.util.Calendar.getInstance;
023import static java.util.Spliterator.IMMUTABLE;
024import static java.util.Spliterators.spliteratorUnknownSize;
025import static java.util.TimeZone.getTimeZone;
026import static java.util.stream.StreamSupport.stream;
027import static javax.ws.rs.core.HttpHeaders.ALLOW;
028import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE;
029import static javax.ws.rs.core.HttpHeaders.LINK;
030import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
031import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
032import static javax.ws.rs.core.Response.Status.CONFLICT;
033import static javax.ws.rs.core.Response.Status.CREATED;
034import static javax.ws.rs.core.Response.Status.NO_CONTENT;
035import static javax.ws.rs.core.Response.Status.OK;
036import static org.apache.jena.graph.Node.ANY;
037import static org.apache.jena.graph.NodeFactory.createLiteral;
038import static org.apache.jena.graph.NodeFactory.createLiteralByValue;
039import static org.apache.jena.graph.NodeFactory.createURI;
040import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
041import static org.apache.jena.rdf.model.ModelFactory.createModelForGraph;
042import static org.apache.jena.rdf.model.ResourceFactory.createProperty;
043import static org.fcrepo.http.commons.test.util.TestHelpers.parseTriples;
044import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
045import static org.fcrepo.kernel.api.RdfLexicon.BASIC_CONTAINER;
046import static org.fcrepo.kernel.api.RdfLexicon.CREATED_BY;
047import static org.fcrepo.kernel.api.RdfLexicon.CREATED_DATE;
048import static org.fcrepo.kernel.api.RdfLexicon.DIRECT_CONTAINER;
049import static org.fcrepo.kernel.api.RdfLexicon.HAS_MESSAGE_DIGEST;
050import static org.fcrepo.kernel.api.RdfLexicon.LAST_MODIFIED_BY;
051import static org.fcrepo.kernel.api.RdfLexicon.LAST_MODIFIED_DATE;
052import static org.fcrepo.kernel.api.RdfLexicon.NON_RDF_SOURCE;
053import static org.fcrepo.kernel.api.RdfLexicon.PREFER_SERVER_MANAGED;
054import static org.junit.Assert.assertEquals;
055import static org.junit.Assert.assertFalse;
056import static org.junit.Assert.assertTrue;
057import static org.junit.Assert.fail;
058
059import java.io.ByteArrayInputStream;
060import java.io.ByteArrayOutputStream;
061import java.io.IOException;
062import java.time.Instant;
063import java.time.temporal.ChronoUnit;
064import java.util.Arrays;
065import java.util.Calendar;
066import java.util.HashSet;
067import java.util.Iterator;
068import java.util.Set;
069import java.util.stream.Stream;
070
071import javax.ws.rs.core.Link;
072import javax.xml.bind.DatatypeConverter;
073
074import org.fcrepo.config.ServerManagedPropsMode;
075import org.fcrepo.http.commons.test.util.CloseableDataset;
076import org.fcrepo.kernel.api.utils.GraphDifferencer;
077
078import org.apache.commons.io.IOUtils;
079import org.apache.http.client.methods.CloseableHttpResponse;
080import org.apache.http.client.methods.HttpDelete;
081import org.apache.http.client.methods.HttpGet;
082import org.apache.http.client.methods.HttpPatch;
083import org.apache.http.client.methods.HttpPost;
084import org.apache.http.client.methods.HttpPut;
085import org.apache.http.entity.ByteArrayEntity;
086import org.apache.http.entity.StringEntity;
087import org.apache.http.util.EntityUtils;
088import org.apache.jena.datatypes.xsd.XSDDatatype;
089import org.apache.jena.datatypes.xsd.XSDDateTime;
090import org.apache.jena.datatypes.xsd.impl.XSDDateTimeType;
091import org.apache.jena.graph.Node;
092import org.apache.jena.graph.Triple;
093import org.apache.jena.query.Dataset;
094import org.apache.jena.rdf.model.Model;
095import org.apache.jena.rdf.model.Property;
096import org.apache.jena.rdf.model.Statement;
097import org.apache.jena.rdf.model.StmtIterator;
098import org.apache.jena.riot.RDFDataMgr;
099import org.apache.jena.riot.RDFFormat;
100import org.apache.jena.sparql.core.DatasetGraph;
101import org.apache.jena.sparql.core.Quad;
102import org.apache.jena.vocabulary.RDF;
103import org.junit.After;
104import org.junit.Before;
105import org.junit.Test;
106import org.junit.runner.RunWith;
107import org.springframework.test.context.TestExecutionListeners;
108import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
109
110/**
111 * @author Mike Durbin
112 */
113@RunWith(SpringJUnit4ClassRunner.class)
114@TestExecutionListeners(
115        listeners = { TestIsolationExecutionListener.class },
116        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
117public class FedoraRelaxedLdpIT extends AbstractResourceIT {
118
119    private final String providedUsername = "provided-username";
120    private Calendar providedDate;
121
122    public FedoraRelaxedLdpIT() {
123        providedDate = nowUTC();
124        providedDate.add(Calendar.YEAR, -20);
125    }
126
127    @Before
128    public void switchToRelaxedMode() {
129        propsConfig.setServerManagedPropsMode(ServerManagedPropsMode.RELAXED);
130    }
131
132
133    @Test
134    public void testBasicPutRoundtrip() throws IOException {
135        final String subjectURI;
136        try (final CloseableHttpResponse response = execute(postObjMethod())) {
137            assertEquals(CREATED.getStatusCode(), getStatus(response));
138            subjectURI = getLocation(response);
139        }
140
141        final String body;
142        final HttpGet get = new HttpGet(subjectURI);
143        try (final CloseableHttpResponse response = execute(get)) {
144            assertEquals(OK.getStatusCode(), getStatus(response));
145            body = EntityUtils.toString(response.getEntity());
146        }
147
148        final HttpPut put = new HttpPut(subjectURI);
149        put.setEntity(new StringEntity(body));
150        put.setHeader(CONTENT_TYPE, "text/turtle");
151        put.addHeader("Prefer", "handling=lenient");
152        assertEquals(NO_CONTENT.getStatusCode(), getStatus(put));
153    }
154
155    @Test
156    public void testCreateResourceWithSpecificCreationInformationIsAllowed() throws IOException {
157        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
158        final String subjectURI;
159        try (final CloseableHttpResponse response = postResourceWithTTL(
160                getTTLThatUpdatesServerManagedTriples(providedUsername, providedDate, null, null))) {
161            assertEquals(CREATED.getStatusCode(), getStatus(response));
162            subjectURI = getLocation(response);
163        }
164
165        try (final CloseableDataset dataset = getDataset(new HttpGet(subjectURI))) {
166            triples(subjectURI, dataset)
167                    .mustHave(CREATED_BY.asNode(), createLiteral(providedUsername))
168                    .mustHave(CREATED_DATE.asNode(), createDateTime(providedDate));
169        }
170    }
171
172    @Test
173    public void testUpdateNonRdfResourceWithSpecificInformationIsAllowed() throws IOException {
174        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
175
176        final String subjectURI;
177        try (final CloseableHttpResponse response = postBinaryResource(serverAddress, "this is the binary")) {
178            assertEquals(CREATED.getStatusCode(), getStatus(response));
179            subjectURI = getLocation(response);
180        }
181
182        final String describedByURI = subjectURI + "/fcr:metadata";
183
184        try (final CloseableHttpResponse response = putResourceWithTTL(describedByURI,
185                getTTLThatUpdatesServerManagedTriples(providedUsername, providedDate,
186                                                      providedUsername, providedDate))) {
187            assertEquals(NO_CONTENT.getStatusCode(), getStatus(response));
188        }
189
190        try (final CloseableDataset dataset = getDataset(new HttpGet(describedByURI))) {
191            triples(subjectURI, dataset)
192                    .mustHave(CREATED_BY.asNode(), createLiteral(providedUsername))
193                    .mustHave(CREATED_DATE.asNode(), createDateTime(providedDate))
194                    .mustHave(LAST_MODIFIED_BY.asNode(), createLiteral(providedUsername))
195                    .mustHave(LAST_MODIFIED_DATE.asNode(), createDateTime(providedDate));
196        }
197    }
198
199    @Test
200    public void testValidSparqlUpdate() throws IOException {
201        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
202
203        final String subjectURI;
204        try (final CloseableHttpResponse response = postResourceWithTTL(
205                getTTLThatUpdatesServerManagedTriples(providedUsername, providedDate, null, null))) {
206            assertEquals(CREATED.getStatusCode(), getStatus(response));
207            subjectURI = getLocation(response);
208        }
209
210        final Calendar updatedDate = Calendar.getInstance(getTimeZone("UTC"));
211        final String sparqlUpdate = "PREFIX fedora: <http://fedora.info/definitions/v4/repository#>\n" +
212                "\n" +
213                "DELETE { <> fedora:created \"" + DatatypeConverter.printDateTime(providedDate)
214                        + "\"^^<http://www.w3.org/2001/XMLSchema#dateTime> }\n" +
215                "INSERT { <> fedora:created \"" + DatatypeConverter.printDateTime(updatedDate)
216                        + "\"^^<http://www.w3.org/2001/XMLSchema#dateTime> }\n" +
217                "WHERE { }";
218        try (final CloseableHttpResponse response = patchResource(subjectURI, sparqlUpdate)) {
219            assertEquals(NO_CONTENT.getStatusCode(), getStatus(response));
220        }
221
222        try (final CloseableDataset dataset = getDataset(new HttpGet(subjectURI))) {
223            triples(subjectURI, dataset)
224                    .mustHave(CREATED_BY.asNode(), createLiteral(providedUsername))
225                    .mustHave(CREATED_DATE.asNode(), createDateTime(updatedDate));
226        }
227    }
228
229    @Test
230    public void testInvalidSparqlUpdate() throws IOException {
231        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
232
233        final String subjectURI;
234        try (final CloseableHttpResponse response = execute(postObjMethod())) {
235            assertEquals(CREATED.getStatusCode(), getStatus(response));
236            subjectURI = getLocation(response);
237        }
238
239        final Calendar updatedDate = Calendar.getInstance(getTimeZone("UTC"));
240        final String sparqlUpdate = "PREFIX fedora: <http://fedora.info/definitions/v4/repository#>\n" +
241                "\n" +
242                "INSERT { <> fedora:created \"" + DatatypeConverter.printDateTime(updatedDate) +
243                "\"^^<http://www.w3.org/2001/XMLSchema#dateTime> .\n" +
244                " <> fedora:created \"" + DatatypeConverter.printDateTime(providedDate) +
245                "\"^^<http://www.w3.org/2001/XMLSchema#dateTime> }\n" +
246                "WHERE { }";
247        try (final CloseableHttpResponse response = patchResource(subjectURI, sparqlUpdate)) {
248            assertEquals(BAD_REQUEST.getStatusCode(), getStatus(response));
249        }
250
251        try (final CloseableDataset dataset = getDataset(new HttpGet(subjectURI))) {
252            triples(subjectURI, dataset)
253                    .mustNotHave(CREATED_BY.asNode(), createLiteral(providedUsername))
254                    .mustNotHave(CREATED_DATE.asNode(), createDateTime(updatedDate));
255        }
256    }
257
258    @Test
259    public void testUpdateResourceWithSpecificModificationInformationIsAllowed() throws IOException {
260        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
261
262        final String subjectURI;
263        try (final CloseableHttpResponse response = createObject()) {
264            assertEquals(CREATED.getStatusCode(), getStatus(response));
265            subjectURI = getLocation(response);
266        }
267
268        providedDate = nowUTC();
269        providedDate.add(Calendar.MONTH, 1);
270
271        try (final CloseableHttpResponse response = putResourceWithTTL(subjectURI,
272                getTTLThatUpdatesServerManagedTriples(null, null, providedUsername, providedDate))) {
273            assertEquals(NO_CONTENT.getStatusCode(), getStatus(response));
274        }
275
276        try (final CloseableDataset dataset = getDataset(new HttpGet(subjectURI))) {
277            triples(subjectURI, dataset)
278                    .mustHave(LAST_MODIFIED_BY.asNode(), createLiteral(providedUsername))
279                    .mustHave(LAST_MODIFIED_DATE.asNode(), createDateTime(providedDate));
280        }
281    }
282
283    /**
284     * Tests a lossless roundtrip of a resource.
285     * @throws IOException if an error occurs while reading or writing to repository over HTTP
286     */
287    @Test
288    public void testRoundtripping() throws IOException {
289        assertEquals(ServerManagedPropsMode.RELAXED, propsConfig.getServerManagedPropsMode()); // sanity check
290
291        // POST a resource with one user-managed triple
292        final String containerURI;
293        try (final CloseableHttpResponse response
294                     = postResourceWithTTL("<> <http://purl.org/dc/elements/1.1/title> 'title' .")) {
295            assertEquals(CREATED.getStatusCode(), getStatus(response));
296            containerURI = getLocation(response);
297        }
298
299        // POST a non-rdf child resource
300        final String containedBinaryURI;
301        final String containedBinaryDescriptionURI;
302        try (final CloseableHttpResponse response = postBinaryResource(containerURI, "content")) {
303            assertEquals(CREATED.getStatusCode(), getStatus(response));
304            containedBinaryURI = getLocation(response);
305            containedBinaryDescriptionURI = containedBinaryURI + "/fcr:metadata";
306        }
307
308        // export the RDF of the container
309        final String containerBody;
310        try (final CloseableHttpResponse response = execute(new HttpGet(containerURI))) {
311            assertEquals(OK.getStatusCode(), getStatus(response));
312            containerBody = EntityUtils.toString(response.getEntity());
313        }
314
315        // export the RDF of the child
316        final String containedBinaryDescriptionBody;
317        try (final CloseableHttpResponse response = execute(new HttpGet(containedBinaryDescriptionURI))) {
318            assertEquals(OK.getStatusCode(), getStatus(response));
319            containedBinaryDescriptionBody = EntityUtils.toString(response.getEntity());
320        }
321
322
323        // delete the container and its tombstone
324        try (final CloseableHttpResponse response = execute(new HttpDelete(containerURI))) {
325            assertEquals(NO_CONTENT.getStatusCode(), getStatus(response));
326            try (final CloseableHttpResponse r2 = execute(new HttpGet(containerURI))) {
327                final Link tombstone = Link.valueOf(r2.getFirstHeader(LINK).getValue());
328                assertEquals("hasTombstone", tombstone.getRel());
329                assertEquals(NO_CONTENT.getStatusCode(), getStatus(new HttpDelete(tombstone.getUri())));
330            }
331        }
332
333
334        // post the container from the export
335        final String containerRdf = filterRdf(containerBody, CREATED_BY, CREATED_DATE, LAST_MODIFIED_BY,
336                LAST_MODIFIED_DATE, createProperty("http://purl.org/dc/elements/1.1/title"));
337        try (final CloseableHttpResponse response = postResourceWithTTL(containerURI, containerRdf)) {
338            assertEquals(CREATED.getStatusCode(), getStatus(response));
339        }
340
341        // post the binary then patch its metadata to match the export
342        try (final CloseableHttpResponse response = postBinaryResource(containerURI,
343                containedBinaryURI.substring(containerURI.length() + 1), "content")) {
344            assertEquals(CREATED.getStatusCode(), getStatus(response));
345        }
346        final String sparqlUpdate;
347        try (final CloseableDataset d = getDataset(new HttpGet(containedBinaryDescriptionURI))) {
348            final Model model = createDefaultModel();
349            model.read(new ByteArrayInputStream(containedBinaryDescriptionBody.getBytes()), "", "N3");
350            final Stream<Statement> statements =
351                    stream(spliteratorUnknownSize(d.getDefaultModel().listStatements(), IMMUTABLE), false);
352            final GraphDifferencer diff = new GraphDifferencer(model, statements.map(Statement::asTriple));
353            sparqlUpdate = buildSparqlUpdate(diff.difference(), diff.notCommon(), CREATED_BY, CREATED_DATE,
354                    LAST_MODIFIED_BY, LAST_MODIFIED_DATE);
355        }
356
357        try (final CloseableHttpResponse response = patchResource(containedBinaryDescriptionURI, sparqlUpdate)) {
358            assertEquals(NO_CONTENT.getStatusCode(), getStatus(response));
359        }
360
361        // Because the creation of the contained resource resulted in an implicit modification to
362        // the triples on the parent, we need to overwrite them again...  This is done doing a PUT
363        // but could be achieved using a sparql update.
364        assertEquals(NO_CONTENT.getStatusCode(), getStatus(putResourceWithTTL(containerURI, containerRdf)));
365
366        assertIdentical(containerURI, containerBody);
367        assertIdentical(containedBinaryDescriptionURI, containedBinaryDescriptionBody);
368    }
369
370    @Test
371    public void testChangeInteractionModelPutRdf() throws Exception {
372        final var postBinary = postObjMethod();
373        postBinary.setHeader(CONTENT_TYPE, "text/plain");
374        postBinary.setEntity(new StringEntity("some test data"));
375        final String binaryUri;
376        // Create a binary
377        try (final var response = execute(postBinary)) {
378            assertEquals(CREATED.getStatusCode(), getStatus(response));
379            binaryUri = getLocation(response);
380        }
381        final Node binaryNode = createURI(binaryUri);
382        final String oldDate;
383        // Get the binary description
384        try (final var dataset = getDataset(new HttpGet(binaryUri + "/" + FCR_METADATA))) {
385            final var graph = dataset.asDatasetGraph();
386            // Assert it has a NonRDFSource type
387            assertTrue(graph.contains(ANY, binaryNode, RDF.type.asNode(), NON_RDF_SOURCE.asNode()));
388            final var iter = graph.find(ANY, binaryNode, CREATED_DATE.asNode(), ANY);
389            assertTrue(iter.hasNext());
390            final var stmt = iter.next();
391            // We'll change the createdDate
392            oldDate = stmt.getObject().getLiteralValue().toString();
393        }
394        // Now GET without server managed properties
395        final ByteArrayOutputStream newModel = new ByteArrayOutputStream();
396        final var newDate = "2000-01-01T00:00:00Z";
397        // Instant one day after the day we are trying to set.
398        final var newDatePlusOneInstant = Instant.from(ISO_INSTANT.parse(newDate))
399                .plus(1, ChronoUnit.DAYS);
400        final var getDesc = new HttpGet(binaryUri + "/" + FCR_METADATA);
401        getDesc.addHeader("Prefer", preferLink(null, PREFER_SERVER_MANAGED.getURI()));
402        try (final var dataset = getDataset(getDesc)) {
403            final var graph = dataset.asDatasetGraph();
404            // Now add some fields to the new model
405            final var newGraph = graph.getDefaultGraph();
406            newGraph.add(Triple.create(binaryNode, RDF.type.asNode(), DIRECT_CONTAINER.asNode()));
407            newGraph.add(Triple.create(binaryNode, CREATED_DATE.asNode(),
408                    createLiteral(newDate, XSDDateTimeType.XSDdateTime)));
409            RDFDataMgr.write(newModel, newGraph, RDFFormat.NTRIPLES_UTF8);
410        }
411        // Now PUT this new graph back
412        final var putBinaryDescription = new HttpPut(binaryUri + "/" + FCR_METADATA);
413        putBinaryDescription.setHeader(CONTENT_TYPE, "application/n-triples");
414        putBinaryDescription.setEntity(new ByteArrayEntity(newModel.toByteArray()));
415        assertEquals(CONFLICT.getStatusCode(), getStatus(putBinaryDescription));
416        // Check that the date didn't change either.
417        try (final var dataset = getDataset(new HttpGet(binaryUri + "/" + FCR_METADATA))) {
418            final var graph = dataset.asDatasetGraph();
419            // Assert it has a NonRDFSource type
420            assertTrue(graph.contains(ANY, binaryNode, RDF.type.asNode(), NON_RDF_SOURCE.asNode()));
421            final var dateIter = graph.find(ANY, binaryNode, CREATED_DATE.asNode(), ANY);
422            // Assert at least one date
423            assertTrue(dateIter.hasNext());
424            final var graphDate = dateIter.next();
425            // Assert only one date
426            assertFalse(dateIter.hasNext());
427            final var graphDateInstant =
428                    Instant.from(ISO_INSTANT.parse(graphDate.getObject().getLiteralValue().toString()));
429            // Assert the date is after the date we tried to set
430            assertTrue(graphDateInstant.isAfter(newDatePlusOneInstant));
431            assertTrue(graph.contains(ANY, binaryNode, CREATED_DATE.asNode(),
432                    createLiteral(oldDate, XSDDateTimeType.XSDdateTime)));
433            assertFalse(graph.contains(ANY, binaryNode, RDF.type.asNode(), DIRECT_CONTAINER.asNode()));
434            assertFalse(graph.contains(ANY, binaryNode, CREATED_DATE.asNode(),
435                    createLiteral(newDate, XSDDateTimeType.XSDdateTime)));
436        }
437    }
438
439    @Test
440    public void testChangeInteractionModelPatchRdf() throws Exception {
441        final var postBinary = postObjMethod();
442        postBinary.setHeader(CONTENT_TYPE, "text/plain");
443        postBinary.setEntity(new StringEntity("some test data"));
444        final String binaryUri;
445        // Create a binary
446        try (final var response = execute(postBinary)) {
447            assertEquals(CREATED.getStatusCode(), getStatus(response));
448            binaryUri = getLocation(response);
449        }
450        final Node binaryNode = createURI(binaryUri);
451        // Get the binary description
452        try (final var dataset = getDataset(new HttpGet(binaryUri + "/" + FCR_METADATA))) {
453            final var graph = dataset.asDatasetGraph();
454            // Assert it has a NonRDFSource type
455            assertTrue(graph.contains(ANY, binaryNode, RDF.type.asNode(), NON_RDF_SOURCE.asNode()));
456            assertTrue(graph.contains(ANY, binaryNode, CREATED_DATE.asNode(), ANY));
457        }
458        // Now PUT this new graph back
459        final var newDate = "2000-01-01T00:00:00Z";
460        // Instant one day after the day we are trying to set.
461        final var newDatePlusOneInstant = Instant.from(ISO_INSTANT.parse(newDate))
462                .plus(1, ChronoUnit.DAYS);
463                final var patchBinaryDescription = new HttpPatch(binaryUri + "/" + FCR_METADATA);
464        patchBinaryDescription.setHeader(CONTENT_TYPE, "application/sparql-update");
465        patchBinaryDescription.setEntity(new StringEntity("DELETE { <> <" + RDF.type + "> ?type ; " +
466                "<" + CREATED_DATE + "> ?date . } INSERT { <> <" + RDF.type + "> <" + DIRECT_CONTAINER + "> ; " +
467                "<" + CREATED_DATE + "> \"" + newDate + "\"^^<" +
468                XSDDateTimeType.XSDdateTime.getURI() + "> . } WHERE { <> <" + RDF.type + "> ?type ; " +
469                "<" + CREATED_DATE + "> ?date . } "));
470        assertEquals(CONFLICT.getStatusCode(), getStatus(patchBinaryDescription));
471        // Check that the date didn't change either.
472        try (final var dataset = getDataset(new HttpGet(binaryUri + "/" + FCR_METADATA))) {
473            final var graph = dataset.asDatasetGraph();
474            // Assert it has a NonRDFSource type
475            assertTrue(graph.contains(ANY, binaryNode, RDF.type.asNode(), NON_RDF_SOURCE.asNode()));
476            assertFalse(graph.contains(ANY, binaryNode, RDF.type.asNode(), DIRECT_CONTAINER.asNode()));
477            final var dateIter = graph.find(ANY, binaryNode, CREATED_DATE.asNode(), ANY);
478            // Assert at least one date
479            assertTrue(dateIter.hasNext());
480            final var graphDate = dateIter.next();
481            // Assert only one date
482            assertFalse(dateIter.hasNext());
483            final var graphDateInstant =
484                    Instant.from(ISO_INSTANT.parse(graphDate.getObject().getLiteralValue().toString()));
485            // Assert the date is after the date we tried to set
486            assertTrue(graphDateInstant.isAfter(newDatePlusOneInstant));
487        }
488    }
489
490    @Test
491    public void testPutNonRelaxablePredicates() throws Exception {
492        final var postBinary = postObjMethod();
493        postBinary.setHeader(CONTENT_TYPE, "text/plain");
494        postBinary.setEntity(new StringEntity("some test data"));
495        final String binaryUri;
496        // Create a binary
497        try (final var response = execute(postBinary)) {
498            assertEquals(CREATED.getStatusCode(), getStatus(response));
499            binaryUri = getLocation(response);
500        }
501        final String binaryDescription = binaryUri + "/" + FCR_METADATA;
502        // Get the original body.
503        final var getObj = new HttpGet(binaryDescription);
504        getObj.addHeader(ALLOW, "application/n-triples");
505        final String originalBody;
506        try (final var response = execute(getObj)) {
507            assertEquals(OK.getStatusCode(), getStatus(response));
508            originalBody = IOUtils.toString(response.getEntity().getContent(), UTF_8);
509        }
510        // Can't set a non-relaxable property, even in relaxed mode.
511        final var put = new HttpPut(binaryDescription);
512        put.setHeader(CONTENT_TYPE, "application/n-triples");
513        put.setEntity(new StringEntity("<" + binaryUri + "> <" + HAS_MESSAGE_DIGEST + "> \"not a real digest\" .\n" +
514                "<" + binaryUri + "> <" + CREATED_BY + "> \"some-test-user\" ."));
515        assertEquals(CONFLICT.getStatusCode(), getStatus(put));
516
517        // Get the body again.
518        final String newBody;
519        try (final var response = execute(getObj)) {
520            assertEquals(OK.getStatusCode(), getStatus(response));
521            newBody = IOUtils.toString(response.getEntity().getContent(), UTF_8);
522        }
523        confirmResponseBodyNTriplesAreEqual(originalBody, newBody);
524    }
525
526    @Test
527    public void testPatchNonRelaxablePredicates() throws Exception {
528        final var postBinary = postObjMethod();
529        postBinary.setHeader(CONTENT_TYPE, "text/plain");
530        postBinary.setEntity(new StringEntity("some test data"));
531        final String binaryUri;
532        // Create a binary
533        try (final var response = execute(postBinary)) {
534            assertEquals(CREATED.getStatusCode(), getStatus(response));
535            binaryUri = getLocation(response);
536        }
537        final String binaryDescription = binaryUri + "/" + FCR_METADATA;
538        // Get the original body.
539        final var getObj = new HttpGet(binaryDescription);
540        getObj.addHeader(ALLOW, "application/n-triples");
541        final String originalBody;
542        try (final var response = execute(getObj)) {
543            assertEquals(OK.getStatusCode(), getStatus(response));
544            originalBody = IOUtils.toString(response.getEntity().getContent(), UTF_8);
545        }
546
547        // Can't patch a non-relaxable property, even in relaxed mode.
548        final var patch = new HttpPatch(binaryDescription);
549        patch.setHeader(CONTENT_TYPE, "application/sparql-update");
550        patch.setEntity(new StringEntity("DELETE { <> <" + HAS_MESSAGE_DIGEST + "> ?digest } INSERT { " +
551                "<> <" + HAS_MESSAGE_DIGEST + "> \"fake-digest\". } WHERE { <> <" + HAS_MESSAGE_DIGEST +
552                "> ?digest }"));
553        assertEquals(CONFLICT.getStatusCode(), getStatus(patch));
554
555        // Get the body again.
556        final String newBody;
557        try (final var response = execute(getObj)) {
558            assertEquals(OK.getStatusCode(), getStatus(response));
559            newBody = IOUtils.toString(response.getEntity().getContent(), UTF_8);
560        }
561        confirmResponseBodyNTriplesAreEqual(originalBody, newBody);
562    }
563
564    @After
565    public void switchToStrictMode() {
566        propsConfig.setServerManagedPropsMode(ServerManagedPropsMode.STRICT);
567    }
568
569    private void assertIdentical(final String uri, final String originalRdf) throws IOException {
570        try (final CloseableDataset roundtripped = getDataset(new HttpGet(uri))) {
571            try (final CloseableDataset original = parseTriples(new ByteArrayInputStream(originalRdf.getBytes()))) {
572                final DatasetGraph originalGraph = original.asDatasetGraph();
573                final DatasetGraph roundtrippedGraph = roundtripped.asDatasetGraph();
574                final Iterator<Quad> originalQuadIt = originalGraph.find();
575                while (originalQuadIt.hasNext()) {
576                    final Quad q = originalQuadIt.next();
577                    if (!assertQuadsAreSimilar(q, roundtrippedGraph)) {
578                        fail(q + " should be preserved through a roundtrip! \nOriginal RDF: " + originalRdf
579                                + "\nRoundtripped Graph:\n" + roundtrippedGraph);
580                    }
581                    roundtrippedGraph.delete(q);
582                }
583                assertTrue("Roundtripped graph had extra quads! " + roundtrippedGraph, roundtrippedGraph.isEmpty());
584            }
585        }
586    }
587
588    /**
589     * In roundtripping XSDDateTimes get truncated to 3 digits of microseconds. This checks that if the quad doesn't
590     * match and is a XSDDateTime that it is within .001 of a second.
591     *
592     * @param testQuad the quad to check
593     * @param checkGraph the graph to verify against
594     * @return true if the quad is exact or very close, false if not.
595     */
596    private boolean assertQuadsAreSimilar(final Quad testQuad, final DatasetGraph checkGraph) {
597        if (checkGraph.contains(testQuad)) {
598            return true;
599        } else if (testQuad.getObject().isLiteral() &&
600                testQuad.getObject().getLiteral().getDatatype() instanceof XSDDateTimeType) {
601            final var testLiteral = (XSDDateTime)testQuad.getObject().getLiteralValue();
602            if (checkGraph.contains(ANY, testQuad.getSubject(), testQuad.getPredicate(), ANY)) {
603                final var roundTripIter =
604                        checkGraph.find(ANY, testQuad.getSubject(), testQuad.getPredicate(), ANY);
605                while (roundTripIter.hasNext()) {
606                    final var dateStmt = roundTripIter.next();
607                    final var comparisonVal = (XSDDateTime) dateStmt.getObject().getLiteralValue();
608                    if (testLiteral.getYears() == comparisonVal.getYears() &&
609                        testLiteral.getMonths() == comparisonVal.getMonths() &&
610                        testLiteral.getDays() == comparisonVal.getDays() &&
611                        testLiteral.getHours() == comparisonVal.getHours() &&
612                        testLiteral.getMinutes() == comparisonVal.getMinutes() &&
613                        testLiteral.getFullSeconds() == comparisonVal.getFullSeconds() &&
614                        Math.abs(testLiteral.getSeconds() - comparisonVal.getSeconds()) < .001) {
615                            // Delete here as it won't actually match the quad
616                            checkGraph.delete(dateStmt);
617                            return true;
618                    }
619                }
620            }
621        }
622        return false;
623    }
624
625    private String buildSparqlUpdate(final Stream<Triple> toRemove, final Stream<Triple> toAdd,
626                                     final Property ... predicates) {
627        final Set<Property> allowedPredicates = new HashSet<>(Arrays.asList(predicates));
628        final StringBuilder r = new StringBuilder();
629        r.append("DELETE { \n");
630        toRemove.filter(t -> allowedPredicates.contains(createProperty(t.getPredicate().getURI())))
631                .forEach(triple -> r.append(" <> <" + triple.getPredicate().toString() + "> "
632                        + wrapLiteral(triple.getObject()) + " .\n"));
633        r.append("}\nINSERT { ");
634        toAdd.filter(t -> allowedPredicates.contains(createProperty(t.getPredicate().getURI())))
635                .forEach(triple -> r.append(" <> <" + triple.getPredicate().toString() + "> "
636                        + wrapLiteral(triple.getObject()) + " .\n"));
637        r.append("} WHERE {}");
638        return r.toString();
639    }
640
641    private String wrapLiteral(final Node node) {
642        return "\"" + node.getLiteralLexicalForm() + "\"^^<" + node.getLiteralDatatype().getURI() + ">";
643    }
644
645    /**
646     * Parses the provided triples and filters it to just include statements with the
647     * specified predicates.
648     * @param triples to filter
649     * @param predicates to filter on
650     * @return filtered triples
651     */
652    private String filterRdf(final String triples, final Property ... predicates) {
653        try (final CloseableDataset original = parseTriples(new ByteArrayInputStream(triples.getBytes()))) {
654            final Model m = original.getDefaultModel();
655            final StmtIterator it = m.listStatements();
656            while (it.hasNext()) {
657                final Statement stmt = it.nextStatement();
658                boolean keep = false;
659                for (final Property p : predicates) {
660                    if (stmt.getPredicate().equals(p)) {
661                        keep = true;
662                        break;
663                    }
664                }
665                if (!keep) {
666                    it.remove();
667                }
668            }
669            final ByteArrayOutputStream baos = new ByteArrayOutputStream();
670            m.write(baos, "N3");
671            return baos.toString();
672        }
673    }
674
675    private CloseableHttpResponse patchResource(final String uri, final String sparqlUpdate) throws IOException {
676        final HttpPatch patch = new HttpPatch(uri);
677        patch.setHeader("Content-type", "application/sparql-update");
678        patch.setEntity(new StringEntity(sparqlUpdate));
679        return execute(patch);
680    }
681
682    private CloseableHttpResponse postBinaryResource(final String parentURI, final String content) throws IOException {
683        return postBinaryResource(parentURI, null, content);
684    }
685
686    private CloseableHttpResponse postBinaryResource(final String parentURI, final String slug,
687                                                     final String content) throws IOException {
688        final HttpPost post = new HttpPost(parentURI);
689        if (slug != null) {
690            post.setHeader("Slug", slug);
691        }
692        post.setEntity(new StringEntity(content == null ? "" : content));
693        post.setHeader(CONTENT_TYPE, TEXT_PLAIN);
694        post.setHeader(LINK, "<" + NON_RDF_SOURCE.toString() + ">; rel=\"type\"");
695        return execute(post);
696    }
697
698    private CloseableHttpResponse postResourceWithTTL(final String ttl) throws IOException {
699        return this.postResourceWithTTL(null, ttl);
700    }
701
702    private CloseableHttpResponse postResourceWithTTL(final String uri, final String ttl) throws IOException {
703        final HttpPost httpPost = new HttpPost(serverAddress);
704        httpPost.addHeader("Slug", uri == null ? getRandomUniqueId() : uri.substring(serverAddress.length()));
705        httpPost.addHeader(CONTENT_TYPE, "text/turtle");
706        httpPost.addHeader(LINK, "<" + BASIC_CONTAINER.toString() + ">; rel=\"type\"");
707        httpPost.setEntity(new StringEntity(ttl));
708        return execute(httpPost);
709    }
710
711    private CloseableHttpResponse putResourceWithTTL(final String uri, final String ttl)
712            throws IOException {
713        final HttpPut httpPut = new HttpPut(uri);
714        httpPut.addHeader(CONTENT_TYPE, "text/turtle");
715        httpPut.setEntity(new StringEntity(ttl));
716        return execute(httpPut);
717    }
718
719    private ResultGraphWrapper triples(final String uri, final Dataset dataset) {
720        return new ResultGraphWrapper(uri, dataset.asDatasetGraph());
721    }
722
723    private static Calendar nowUTC() {
724        return getInstance(getTimeZone("UTC"));
725    }
726
727    private class ResultGraphWrapper {
728
729        private final DatasetGraph graph;
730        private final Node nodeUri;
731        private final String statements;
732
733        public ResultGraphWrapper(final String subjectURI, final DatasetGraph graph) {
734            this.graph = graph;
735            final Model model = createModelForGraph(graph.getDefaultGraph());
736            nodeUri = createURI(subjectURI);
737
738            final StringBuilder statements = new StringBuilder();
739            final StmtIterator it = model.listStatements();
740            while (it.hasNext()) {
741                statements.append(it.next() + "\n");
742            }
743            this.statements = statements.toString();
744        }
745
746        public ResultGraphWrapper mustHave(final Node predicate, final Node object) {
747            assertTrue(predicate.getLocalName() + " should have been set to " + object.getLiteral().toString()
748                    + "!\nStatements:\n" + statements, graph.contains(ANY, nodeUri, predicate, object));
749            return this;
750        }
751
752        public ResultGraphWrapper mustNotHave(final Node predicate, final Node object) {
753            assertFalse(predicate.getLocalName() + " should not have been set to " + object.getLiteral().toString()
754                    + "!", graph.contains(ANY, nodeUri, predicate, object));
755            return this;
756        }
757
758    }
759
760    private Node createDateTime(final Calendar cal) {
761        return createLiteralByValue(DatatypeConverter.printDateTime(cal), XSDDatatype.XSDdateTime);
762    }
763
764}