diff --git a/web-access-app/src/test/java/edu/unc/lib/boxc/web/access/controllers/FedoraContentControllerIT.java b/web-access-app/src/test/java/edu/unc/lib/boxc/web/access/controllers/FedoraContentControllerIT.java index c146895e30..142aabfa35 100644 --- a/web-access-app/src/test/java/edu/unc/lib/boxc/web/access/controllers/FedoraContentControllerIT.java +++ b/web-access-app/src/test/java/edu/unc/lib/boxc/web/access/controllers/FedoraContentControllerIT.java @@ -130,6 +130,27 @@ public void testGetDatastreamWithRange() throws Exception { assertEquals("inline; filename=\"file.txt\"", response.getHeader(CONTENT_DISPOSITION)); } + @Test + public void testGetDatastreamWithRangeEndingSameAsSize() throws Exception { + PID filePid = makePid(); + + FileObject fileObj = repositoryObjectFactory.createFileObject(filePid, null); + fileObj.addOriginalFile(makeContentUri(originalPid(fileObj), BINARY_CONTENT), "file.txt", "text/plain", null, null); + + MvcResult result = mvc.perform(get("/content/" + filePid.getId()) + .header(RANGE,"bytes=0-14")) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + + // Verify content was retrieved + MockHttpServletResponse response = result.getResponse(); + assertEquals(BINARY_CONTENT, response.getContentAsString()); + + assertEquals(BINARY_CONTENT.length(), response.getContentAsString().length()); + assertEquals("text/plain", response.getContentType()); + assertEquals("inline; filename=\"file.txt\"", response.getHeader(CONTENT_DISPOSITION)); + } + @Test public void testGetDatastreamDownload() throws Exception { PID filePid = makePid(); @@ -284,7 +305,7 @@ public void testRangeExceedsFileLength() throws Exception { fileObj.addOriginalFile(makeContentUri(originalPid(fileObj), BINARY_CONTENT), null, "text/plain", null, null); mvc.perform(get("/indexablecontent/" + filePid.getId()) - .header(RANGE,"bytes=0-900000")) + .header(RANGE,"bytes=900000-900000")) .andExpect(status().isRequestedRangeNotSatisfiable()) .andReturn(); } diff --git a/web-common/src/main/java/edu/unc/lib/boxc/web/common/services/FedoraContentService.java b/web-common/src/main/java/edu/unc/lib/boxc/web/common/services/FedoraContentService.java index f5f445910b..99465ec4ab 100644 --- a/web-common/src/main/java/edu/unc/lib/boxc/web/common/services/FedoraContentService.java +++ b/web-common/src/main/java/edu/unc/lib/boxc/web/common/services/FedoraContentService.java @@ -96,6 +96,9 @@ public void streamData(PID pid, String dsName, boolean asAttachment, binObj = repositoryObjectLoader.getBinaryObject(dsPid); } + // Make sure the range is valid or will produce a reasonable response from fedora + range = correctRangeValue(range, binObj); + response.setHeader(CONTENT_TYPE, binObj.getMimetype()); String binaryName = binObj.getFilename(); String filename = binaryName == null ? pid.getId() : binaryName; @@ -116,6 +119,25 @@ public void streamData(PID pid, String dsName, boolean asAttachment, } } + private String correctRangeValue(String range, BinaryObject binObj) { + if (range == null) { + return null; + } + var rangeParts = range.split("-"); + if (rangeParts.length == 2) { + var endingRange = rangeParts[1]; + try { + var endingValue = Long.parseLong(endingRange); + if (endingValue >= binObj.getFilesize()) { + return rangeParts[0] + "-"; + } + } catch (NumberFormatException e) { + LOG.debug("Invalid range ending provided: {}", e.getMessage()); + } + } + return range; + } + public void streamEventLog(PID pid, AccessGroupSet principals, boolean asAttachment, HttpServletResponse response) throws IOException { diff --git a/web-common/src/test/java/edu/unc/lib/boxc/web/common/services/FedoraContentServiceTest.java b/web-common/src/test/java/edu/unc/lib/boxc/web/common/services/FedoraContentServiceTest.java index fd97890d28..f4decde56c 100644 --- a/web-common/src/test/java/edu/unc/lib/boxc/web/common/services/FedoraContentServiceTest.java +++ b/web-common/src/test/java/edu/unc/lib/boxc/web/common/services/FedoraContentServiceTest.java @@ -1,10 +1,18 @@ package edu.unc.lib.boxc.web.common.services; import edu.unc.lib.boxc.auth.api.services.AccessControlService; +import edu.unc.lib.boxc.auth.fcrepo.models.AccessGroupSetImpl; +import edu.unc.lib.boxc.fcrepo.exceptions.RangeNotSatisfiableException; +import edu.unc.lib.boxc.model.api.DatastreamType; +import edu.unc.lib.boxc.model.api.event.PremisLog; import edu.unc.lib.boxc.model.api.exceptions.NotFoundException; +import edu.unc.lib.boxc.model.api.ids.PID; import edu.unc.lib.boxc.model.api.objects.BinaryObject; import edu.unc.lib.boxc.model.api.objects.FileObject; import edu.unc.lib.boxc.model.api.objects.RepositoryObjectLoader; +import edu.unc.lib.boxc.model.api.rdf.DcElements; +import edu.unc.lib.boxc.model.fcrepo.ids.DatastreamPids; +import org.apache.jena.rdf.model.ModelFactory; import org.fcrepo.client.FcrepoClient; import org.fcrepo.client.FcrepoOperationFailedException; import org.fcrepo.client.FcrepoResponse; @@ -19,19 +27,22 @@ import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import static edu.unc.lib.boxc.model.api.DatastreamType.ORIGINAL_FILE; import static edu.unc.lib.boxc.model.fcrepo.test.TestHelper.makePid; import static org.apache.http.HttpHeaders.CONTENT_LENGTH; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.apache.http.HttpHeaders.CONTENT_RANGE; +import static org.apache.http.HttpHeaders.CONTENT_TYPE; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.openMocks; +import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; public class FedoraContentServiceTest { private AutoCloseable closeable; @@ -54,14 +65,18 @@ public class FedoraContentServiceTest { private FcrepoResponse fcrepoResponse; @Mock private ServletOutputStream outputStream; + @Mock + private PremisLog premisLog; @BeforeEach - public void setup() { + public void setup() throws Exception { closeable = openMocks(this); fedoraContentService = new FedoraContentService(); fedoraContentService.setClient(fcrepoClient); fedoraContentService.setAccessControlService(accessControlService); fedoraContentService.setRepositoryObjectLoader(repositoryObjectLoader); + when(response.getOutputStream()).thenReturn(outputStream); + when(fileObject.getPremisLog()).thenReturn(premisLog); } @AfterEach @@ -90,18 +105,151 @@ public void streamDataWithExternalDatastream() { @Test public void streamDataSuccess() throws IOException, FcrepoOperationFailedException { var pid = makePid(); - when(repositoryObjectLoader.getFileObject(eq(pid))).thenReturn(fileObject); - when(fileObject.getOriginalFile()).thenReturn(binaryObject); - when(binaryObject.getFilename()).thenReturn("Best Name"); - when(binaryObject.getPid()).thenReturn(pid); - when(fcrepoClient.get(any())).thenReturn(builder); - when(builder.perform()).thenReturn(fcrepoResponse); + mockWithOriginalFile(pid); when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("image".getBytes(StandardCharsets.UTF_8))); - when(response.getOutputStream()).thenReturn(outputStream); when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("5"); fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), false, response, null); verify(response).setHeader(CONTENT_LENGTH, "5"); + verify(response).setHeader(CONTENT_DISPOSITION, "inline; filename=\"Best Name\""); + } + + @Test + public void streamDataSuccessAsAttachment() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + mockWithOriginalFile(pid); + when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("image".getBytes(StandardCharsets.UTF_8))); + when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("5"); + + fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), true, response, null); + + verify(response).setHeader(CONTENT_LENGTH, "5"); + verify(response).setHeader(CONTENT_DISPOSITION, "attachment; filename=\"Best Name\""); + } + + @Test + public void streamDataSuccessOtherDatastream() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + var modsPid = DatastreamPids.getMdDescriptivePid(pid); + mockWithOriginalFile(pid); + var modsBinary = mock(BinaryObject.class); + when(modsBinary.getFilename()).thenReturn("mods.xml"); + when(modsBinary.getPid()).thenReturn(modsPid); + when(modsBinary.getFilesize()).thenReturn(4L); + when(repositoryObjectLoader.getBinaryObject(eq(modsPid))).thenReturn(modsBinary); + when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("desc".getBytes(StandardCharsets.UTF_8))); + when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("4"); + + fedoraContentService.streamData(pid, DatastreamType.MD_DESCRIPTIVE.getId(), false, response, null); + + verify(response).setHeader(CONTENT_LENGTH, "4"); + verify(response).setHeader(CONTENT_DISPOSITION, "inline; filename=\"mods.xml\""); + } + + @Test + public void streamDataWithValidRange() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + mockWithOriginalFile(pid); + when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("imag".getBytes(StandardCharsets.UTF_8))); + when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("4"); + var contentRange = "bytes 0-3/5"; + when(fcrepoResponse.getHeaderValue(CONTENT_RANGE)).thenReturn(contentRange); + + fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), false, response, "bytes=0-3"); + + verify(response).setHeader(CONTENT_LENGTH, "4"); + verify(response).setHeader(CONTENT_RANGE, contentRange); + verify(builder).addHeader("Range", "bytes=0-3"); + } + + @Test + public void streamDataWithEndRangeSameAsSize() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + mockWithOriginalFile(pid); + when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("image".getBytes(StandardCharsets.UTF_8))); + when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("5"); + var contentRange = "bytes 0-4/5"; + when(fcrepoResponse.getHeaderValue(CONTENT_RANGE)).thenReturn(contentRange); + + fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), false, response, "bytes=0-5"); + + verify(response).setHeader(CONTENT_LENGTH, "5"); + verify(response).setHeader(CONTENT_RANGE, contentRange); + verify(builder).addHeader("Range", "bytes=0-"); + } + + @Test + public void streamDataWithEndRangeGreaterThanSize() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + mockWithOriginalFile(pid); + when(fcrepoResponse.getBody()).thenReturn(new ByteArrayInputStream("mage".getBytes(StandardCharsets.UTF_8))); + when(fcrepoResponse.getHeaderValue(CONTENT_LENGTH)).thenReturn("4"); + var contentRange = "bytes 1-4/5"; + when(fcrepoResponse.getHeaderValue(CONTENT_RANGE)).thenReturn(contentRange); + + fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), false, response, "bytes=1-8"); + + verify(response).setHeader(CONTENT_LENGTH, "4"); + verify(response).setHeader(CONTENT_RANGE, contentRange); + verify(builder).addHeader("Range", "bytes=1-"); + } + + @Test + public void streamDataWithEndRangeInvalid() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + mockWithOriginalFile(pid); + when(builder.perform()).thenThrow(new FcrepoOperationFailedException(null, 416, "Bad Range")); + when(fcrepoResponse.getStatusCode()).thenReturn(416); + + assertThrows(RangeNotSatisfiableException.class, () -> { + fedoraContentService.streamData(pid, ORIGINAL_FILE.getId(), false, response, "bytes=1-bad"); + }); + } + + @Test + public void streamEventLogTest() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + + when(repositoryObjectLoader.getRepositoryObject(eq(pid))).thenReturn(fileObject); + var model = ModelFactory.createDefaultModel(); + when(premisLog.getEventsModel()).thenReturn(model); + var logResource = model.getResource(pid.getRepositoryPath()); + logResource.addProperty(DcElements.title, "test title"); + + var principals = new AccessGroupSetImpl("group"); + + fedoraContentService.streamEventLog(pid, principals, false, response); + + verify(response).setHeader(CONTENT_TYPE, "text/turtle"); + verify(response).setHeader(CONTENT_DISPOSITION, "inline; filename=\"" + pid.getId() + "_event_log.ttl\""); + } + + @Test + public void streamEventLogAttachmentTest() throws IOException, FcrepoOperationFailedException { + var pid = makePid(); + when(repositoryObjectLoader.getRepositoryObject(eq(pid))).thenReturn(fileObject); + + var model = ModelFactory.createDefaultModel(); + when(premisLog.getEventsModel()).thenReturn(model); + var logResource = model.getResource(pid.getRepositoryPath()); + logResource.addProperty(DcElements.title, "test title"); + + var principals = new AccessGroupSetImpl("group"); + + fedoraContentService.streamEventLog(pid, principals, true, response); + + verify(response).setHeader(CONTENT_TYPE, "text/turtle"); + verify(response).setHeader(CONTENT_DISPOSITION, "attachment; filename=\"" + pid.getId() + "_event_log.ttl\""); + } + + private void mockWithOriginalFile(PID pid) throws FcrepoOperationFailedException { + when(repositoryObjectLoader.getFileObject(eq(pid))).thenReturn(fileObject); + when(fileObject.getOriginalFile()).thenReturn(binaryObject); + when(binaryObject.getFilename()).thenReturn("Best Name"); + when(binaryObject.getPid()).thenReturn(pid); + when(binaryObject.getFilesize()).thenReturn(5L); + when(fcrepoClient.get(any())).thenReturn(builder); + when(builder.perform()).thenReturn(fcrepoResponse); } } diff --git a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DatastreamRestControllerIT.java b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DatastreamRestControllerIT.java index 9101921913..60f139c851 100644 --- a/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DatastreamRestControllerIT.java +++ b/web-services-app/src/test/java/edu/unc/lib/boxc/web/services/rest/DatastreamRestControllerIT.java @@ -407,7 +407,7 @@ public void testRangeExceedsFileLength() throws Exception { fileObj.addOriginalFile(contentPath.toUri(), "file.txt", "text/plain", null, null); mvc.perform(get("/file/" + filePid.getId()) - .header(RANGE,"bytes=0-900000")) + .header(RANGE,"bytes=900000-900001")) .andExpect(status().isRequestedRangeNotSatisfiable()) .andReturn(); }