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
020
021import com.fasterxml.jackson.databind.ObjectMapper;
022import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
023import edu.wisc.library.ocfl.api.OcflOption;
024import edu.wisc.library.ocfl.api.OcflRepository;
025import edu.wisc.library.ocfl.api.model.ObjectVersionId;
026import edu.wisc.library.ocfl.api.model.VersionInfo;
027import edu.wisc.library.ocfl.api.model.VersionNum;
028import org.apache.http.HttpStatus;
029import org.apache.http.client.methods.HttpGet;
030import org.apache.jena.graph.Node;
031import org.apache.jena.sparql.core.Quad;
032import org.fcrepo.config.FedoraPropsConfig;
033import org.fcrepo.http.commons.test.util.CloseableDataset;
034import org.fcrepo.kernel.api.FedoraTypes;
035import org.fcrepo.kernel.api.ReadOnlyTransaction;
036import org.fcrepo.kernel.api.Transaction;
037import org.fcrepo.kernel.api.identifiers.FedoraId;
038import org.fcrepo.kernel.impl.TransactionManagerImpl;
039import org.fcrepo.persistence.ocfl.RepositoryInitializer;
040import org.fcrepo.persistence.ocfl.api.FedoraOcflMappingNotFoundException;
041import org.fcrepo.persistence.ocfl.api.FedoraToOcflObjectIndex;
042import org.fcrepo.persistence.ocfl.impl.ReindexService;
043import org.fcrepo.storage.ocfl.ResourceHeaders;
044import org.junit.Assert;
045import org.junit.Before;
046import org.junit.Test;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049import org.springframework.test.context.TestExecutionListeners;
050
051import java.io.IOException;
052import java.io.UncheckedIOException;
053import java.net.URLDecoder;
054import java.nio.charset.StandardCharsets;
055import java.nio.file.Files;
056import java.nio.file.Paths;
057import java.time.Duration;
058import java.time.ZoneOffset;
059import java.util.Collections;
060import java.util.List;
061import java.util.concurrent.TimeUnit;
062import java.util.stream.Collectors;
063import java.util.stream.StreamSupport;
064
065import static java.text.MessageFormat.format;
066import static java.util.Arrays.asList;
067import static javax.ws.rs.core.Response.Status.GONE;
068import static javax.ws.rs.core.Response.Status.OK;
069import static org.apache.jena.graph.Node.ANY;
070import static org.apache.jena.graph.NodeFactory.createURI;
071import static org.fcrepo.kernel.api.FedoraTypes.FCR_METADATA;
072import static org.fcrepo.kernel.api.FedoraTypes.FCR_VERSIONS;
073import static org.fcrepo.kernel.api.RdfLexicon.CONTAINS;
074import static org.junit.Assert.assertEquals;
075import static org.junit.Assert.assertFalse;
076import static org.junit.Assert.fail;
077import static org.springframework.test.util.AssertionErrors.assertTrue;
078
079/**
080 * @author awooods
081 * @since 2020-03-04
082 */
083@TestExecutionListeners(listeners = { TestIsolationExecutionListener.class },
084        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
085public class RebuildIT extends AbstractResourceIT {
086
087    private static final Logger LOGGER = LoggerFactory.getLogger(RebuildIT.class);
088
089    private OcflRepository ocflRepository;
090    private RepositoryInitializer initializer;
091    private ReindexService reindexService;
092    private ObjectMapper objectMapper;
093    private FedoraPropsConfig fedoraPropsConfig;
094    private FedoraToOcflObjectIndex index;
095    private Transaction readOnlyTx;
096    private TransactionManagerImpl txManager;
097
098    private void setBeans() {
099        ocflRepository = getBean(OcflRepository.class);
100        initializer = getBean(RepositoryInitializer.class);
101        reindexService = getBean(ReindexService.class);
102        objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
103        fedoraPropsConfig = getBean(FedoraPropsConfig.class);
104        index = getBean("ocflIndexImpl", FedoraToOcflObjectIndex.class);
105        readOnlyTx = ReadOnlyTransaction.INSTANCE;
106        txManager = getBean(TransactionManagerImpl.class);
107    }
108
109    @Before
110    public void setUp() {
111        setBeans();
112    }
113
114    /**
115     * This test rebuilds from a known set of OCFL content.
116     * The OCFL storage root contains the following four resources:
117     * - root
118     * - /binary
119     * - /test
120     * - /test/child
121     * and a deleted object
122     * - /test/deleted-child
123     *
124     * The test verifies that these objects exist in the rebuilt repository.
125     */
126    @Test
127    public void testRebuildOcfl() {
128        rebuild("test-rebuild-ocfl/objects");
129
130        // Optional debugging
131        if (LOGGER.isDebugEnabled()) {
132            ocflRepository.listObjectIds().forEach(id -> LOGGER.debug("Object id: {}", id));
133        }
134
135        assertEquals(8, ocflRepository.listObjectIds().count());
136        assertTrue("Should contain object with id: " + FedoraTypes.FEDORA_ID_PREFIX,
137                ocflRepository.containsObject(FedoraTypes.FEDORA_ID_PREFIX));
138        assertContains("binary");
139        assertContains("test");
140        assertContains("test/child");
141        assertContains("test/deleted-child");
142        assertContains("archival-group");
143        assertContains("test/nested-archival-group");
144        assertContains("test/nested-binary");
145
146        assertNotContains("archival-group_binary");
147        assertNotContains("archival-group_container");
148        assertNotContains("junk");
149    }
150
151    @Test
152    public void testRebuildOnStart() throws Exception {
153        assertFalse("rebuild on start is disabled", fedoraPropsConfig.isRebuildOnStart());
154        rebuild("test-rebuild-ocfl/objects");
155
156        // Optional debugging
157        if (LOGGER.isDebugEnabled()) {
158            ocflRepository.listObjectIds().forEach(id -> LOGGER.debug("Object id: {}", id));
159        }
160
161        assertTrue("Should contain object with id: " + FedoraTypes.FEDORA_ID_PREFIX,
162                ocflRepository.containsObject(FedoraTypes.FEDORA_ID_PREFIX));
163        assertContains("binary");
164        assertContains("test");
165
166        final var binaryId = FedoraId.create(FedoraTypes.FEDORA_ID_PREFIX + "/binary");
167        this.ocflRepository.purgeObject(binaryId.getFullId());
168
169        //verify that the index st
170        this.index.getMapping(readOnlyTx, binaryId);
171
172        //set rebuild on start flag before initializing
173        restartContainer();
174        setBeans();
175        initializer.initialize();
176
177        //ocfl knows it is now gone
178        assertNotContains("binary");
179
180        //but the index does not know is it gone because no rebuild occurred.
181        this.index.getMapping(readOnlyTx, binaryId);
182
183        //restart the container again, but initialize after setting the rebuild on start
184        restartContainer();
185        setBeans();
186        fedoraPropsConfig.setRebuildOnStart(true);
187        initializer.initialize();
188
189        try {
190            this.index.getMapping(readOnlyTx, binaryId);
191            fail("Expected failure to retrieve mapping");
192        } catch (final FedoraOcflMappingNotFoundException ex) {
193            //intentionally left blank
194        }
195
196    }
197
198    @Test
199    public void testRebuildWebapp() throws Exception {
200        // Set how long tx will live for so that any txs created by the rebuild expire before we attempt to
201        // get the resources they created.
202        propsConfig.setSessionTimeout(Duration.ofSeconds(5));
203
204        rebuild("test-rebuild-ocfl/objects");
205
206        // Wait for txs to expire and ensure they're cleaned up
207        TimeUnit.SECONDS.sleep(5);
208        txManager.cleanupClosedTransactions();
209
210        // Test against the Fedora API
211        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("")));
212        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("test")));
213        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("binary")));
214        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("binary/" + FCR_METADATA)));
215        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("archival-group")));
216        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("archival-group/binary")));
217        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("archival-group/container")));
218        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("test/child")));
219        assertEquals(GONE.getStatusCode(), getStatus(getObjMethod("test/deleted-child")));
220        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("test/nested-archival-group")));
221        assertEquals(OK.getStatusCode(), getStatus(getObjMethod("test/nested-binary")));
222
223        final String testUri = serverAddress + "test";
224        //verify containment relationships
225        verifyContainment(serverAddress, asList("binary", "archival-group", "test"));
226        verifyContainment(serverAddress + "archival-group", asList("binary", "container"));
227        verifyContainment(testUri, asList("child", "nested-archival-group", "nested-binary"),
228                Collections.singletonList("deleted-child"));
229
230        // Get last version of test to see when deleted-child was not deleted
231        final String mementoUri;
232        try (final CloseableDataset dataset = getDataset(getObjMethod("test/" + FCR_VERSIONS))) {
233            final var graph = dataset.asDatasetGraph();
234            final var iter = graph.find(ANY, createURI(serverAddress + "test/" + FCR_VERSIONS), CONTAINS.asNode(), ANY);
235            final Iterable<Quad> iterable = () -> iter;
236            final List<String> versionList = StreamSupport.stream(iterable.spliterator(), false)
237                    .map(Quad::getObject).map(Node::getURI).sorted().collect(Collectors.toList());
238            mementoUri = versionList.get(versionList.size() - 1);
239        }
240
241        verifyContainment(mementoUri, testUri, asList("child", "nested-archival-group",
242                "nested-binary", "deleted-child"));
243    }
244
245    @Test
246    public void rebuildFailsWhenObjectFailsValidation() {
247        rebuild("test-rebuild-invalid");
248
249        assertEquals(HttpStatus.SC_NOT_FOUND, getStatus(getObjMethod("test")));
250        assertEquals(HttpStatus.SC_NOT_FOUND, getStatus(getObjMethod("binary")));
251    }
252
253    private void verifyContainment(final String subjectUri, final List<String> children) throws Exception {
254        verifyContainment(subjectUri, subjectUri, children);
255    }
256
257    private void verifyContainment(final String requestUri, final String subjectUri,
258                                   final List<String> includeChildren) throws Exception {
259        verifyContainment(requestUri, subjectUri, includeChildren, Collections.emptyList());
260    }
261
262    private void  verifyContainment(final String subjectUri, final List<String> includeChildren,
263                                    final List<String> excludeChildren) throws Exception {
264        verifyContainment(subjectUri, subjectUri, includeChildren, excludeChildren);
265    }
266
267    /**
268     * Utility to verify containment triples.
269     *
270     * @param requestUri the URI to request the graph from (could be different as in mementos or binary descriptions)
271     * @param subjectUri the URI to be the subject of the triples.
272     * @param includeChildren list of children expected, only the final path part which is appended to subjectURI.
273     * @param excludeChildren list of children not expected, only the final path part which is appended to subjectURI
274     * @throws Exception
275     */
276    private void verifyContainment(final String requestUri, final String subjectUri, final List<String> includeChildren,
277                                   final List<String> excludeChildren) throws Exception {
278        final var subjectNode = createURI(subjectUri);
279        try (final CloseableDataset dataset = getDataset(new HttpGet(requestUri))) {
280            final var graph = dataset.asDatasetGraph();
281            if (LOGGER.isDebugEnabled()) {
282                graph.listGraphNodes().forEachRemaining(gn -> {
283                    LOGGER.debug("Node = " + gn.toString());
284                });
285            }
286
287            for (final String child : includeChildren) {
288                final var childNode = createURI(subjectUri + (subjectUri.endsWith("/") ? "" : "/") + child);
289                Assert.assertTrue(format("Triple not found: {0}, {1}, {2}", subjectUri,
290                 CONTAINS, childNode),
291                        graph.contains(ANY,
292                        subjectNode,
293                        CONTAINS.asNode(),
294                        childNode));
295            }
296            for (final String child : excludeChildren) {
297                final var childNode = createURI(subjectUri + (subjectUri.endsWith("/") ? "" : "/") + child);
298                Assert.assertFalse(format("Triple found: {0}, {1}, {2}", subjectUri,
299                        CONTAINS, childNode),
300                        graph.contains(ANY,
301                                subjectNode,
302                                CONTAINS.asNode(),
303                                childNode));
304            }
305        }
306    }
307
308    private void assertContains(final String id) {
309        final var fedoraId = FedoraTypes.FEDORA_ID_PREFIX + "/" + id;
310        assertTrue("Should contain object with id: " + fedoraId,
311                ocflRepository.containsObject(fedoraId));
312    }
313
314    private void assertNotContains(final String id) {
315        final var fedoraId = FedoraTypes.FEDORA_ID_PREFIX + "/" + id;
316        assertFalse("Should NOT contain object with id: " + fedoraId,
317                ocflRepository.containsObject(fedoraId));
318    }
319
320    private void rebuild(final String name) {
321        copyToOcfl(name);
322        reindexService.reset();
323        initializer.initialize();
324    }
325
326    private void copyToOcfl(final String name) {
327        try {
328            // this is necessary so that the cache is cleared
329            ocflRepository.listObjectIds().forEach(ocflRepository::purgeObject);
330
331            try (final var list = Files.list(Paths.get("src/test/resources", name))) {
332                list.filter(Files::isDirectory).forEach(dir -> {
333                    final var objectId = URLDecoder.decode(dir.getFileName().toString(), StandardCharsets.UTF_8);
334                    var currentVersion = VersionNum.fromInt(1);
335                    var currentDir = dir.resolve(currentVersion.toString());
336
337                    while (Files.exists(currentDir)) {
338                        try {
339                            final var headers = objectMapper.readValue(
340                                    currentDir.resolve(".fcrepo/fcr-root.json").toFile(), ResourceHeaders.class);
341                            ocflRepository.putObject(ObjectVersionId.head(objectId), currentDir,
342                                    new VersionInfo().setCreated(
343                                            headers.getLastModifiedDate().atOffset(ZoneOffset.UTC)),
344                                    OcflOption.OVERWRITE);
345                            currentVersion = currentVersion.nextVersionNum();
346                            currentDir = dir.resolve(currentVersion.toString());
347                        } catch (IOException e) {
348                            throw new UncheckedIOException(e);
349                        }
350                    }
351                });
352            }
353        } catch (IOException e) {
354            throw new UncheckedIOException(e);
355        }
356    }
357
358}