From a776abc15897b06510dfc0c397088a83dc2aeb7d Mon Sep 17 00:00:00 2001 From: Matus Beke Date: Mon, 20 Jul 2026 07:58:07 +0200 Subject: [PATCH 1/2] Add bitstream-level access status/embargo date REST endpoint Ports the intent of upstream commits ed02a605, 43e41e0f, 6a229f15, and 0a0b5752 (dataquest-dev/DSpace 9.x line) onto zcu-pub's DSpace 7.6.1 codebase. A raw cherry-pick was not viable: upstream uses java.time.LocalDate + jakarta.* and refactors the helper around a Pair/AccessStatus object with a current-user-vs- anonymous calculation type, none of which match zcu-pub's simpler joda-time + javax.* shape. Instead, re-derive the minimal intent by generalizing the existing item-only helper methods to also accept a Bitstream, reusing the already-DSpaceObject-generic calculateAccessStatusForDso() and the bitstream-scoped retrieveShortestEmbargo(). - AccessStatusHelper / DefaultAccessStatusHelper / AccessStatusServiceImpl: add getAccessStatusFromBitstream / getEmbargoFromBitstream and their AccessStatusService-level counterparts. - AccessStatusRest: add the embargoDate field (previously only used at item level; zcu-pub's item-level link repository never actually populated it despite the service already computing it, so ItemAccessStatusLinkRepository now also sets it for consistency). - BitstreamRest: add the ACCESS_STATUS link. - New BitstreamAccessStatusLinkRepository, mirroring ItemAccessStatusLinkRepository and BitstreamFormatLinkRepository's existing METADATA_READ permission convention. - Tests: extend AccessStatusServiceTest, DefaultAccessStatusHelperTest, AccessStatusRestTest, and BitstreamRestRepositoryIT for the new bitstream-level behavior. Deliberately does not port the AccessStatus-object/Pair refactor or the current-user-vs-anonymous calculation type from the later upstream commits (6a229f15, 0a0b5752) -- that's 9.x-specific complexity not present in zcu-pub's simpler anonymous-only model. Co-Authored-By: Claude Sonnet 5 --- .../access/status/AccessStatusHelper.java | 24 +++++++ .../status/AccessStatusServiceImpl.java | 11 +++ .../status/DefaultAccessStatusHelper.java | 57 +++++++++++++++- .../status/service/AccessStatusService.java | 21 ++++++ .../status/AccessStatusServiceTest.java | 52 ++++++++++++++ .../status/DefaultAccessStatusHelperTest.java | 36 +++++++++- .../app/rest/model/AccessStatusRest.java | 11 +++ .../dspace/app/rest/model/BitstreamRest.java | 5 ++ .../BitstreamAccessStatusLinkRepository.java | 67 +++++++++++++++++++ .../ItemAccessStatusLinkRepository.java | 5 ++ .../app/rest/BitstreamRestRepositoryIT.java | 48 +++++++++++++ .../app/rest/matcher/BitstreamMatcher.java | 6 +- .../app/rest/model/AccessStatusRestTest.java | 13 +++- 13 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java index 2d782dc3b82..080033c6caa 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusHelper.java @@ -10,6 +10,7 @@ import java.sql.SQLException; import java.util.Date; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; @@ -39,4 +40,27 @@ public String getAccessStatusFromItem(Context context, Item item, Date threshold * @throws SQLException An exception that provides information on a database access error or other errors. */ public String getEmbargoFromItem(Context context, Item item, Date threshold) throws SQLException; + + /** + * Calculate the access status for the bitstream. + * + * @param context the DSpace context + * @param bitstream the bitstream + * @param threshold the embargo threshold date + * @return an access status value + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public String getAccessStatusFromBitstream(Context context, Bitstream bitstream, Date threshold) + throws SQLException; + + /** + * Retrieve embargo information for the bitstream + * + * @param context the DSpace context + * @param bitstream the bitstream to check for embargo information + * @param threshold the embargo threshold date + * @return an embargo date + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public String getEmbargoFromBitstream(Context context, Bitstream bitstream, Date threshold) throws SQLException; } diff --git a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java index e1f11285d84..977e7c2e4a4 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/access/status/AccessStatusServiceImpl.java @@ -11,6 +11,7 @@ import java.util.Date; import org.dspace.access.status.service.AccessStatusService; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; import org.dspace.core.service.PluginService; @@ -68,4 +69,14 @@ public String getAccessStatus(Context context, Item item) throws SQLException { public String getEmbargoFromItem(Context context, Item item) throws SQLException { return helper.getEmbargoFromItem(context, item, forever_date); } + + @Override + public String getAccessStatus(Context context, Bitstream bitstream) throws SQLException { + return helper.getAccessStatusFromBitstream(context, bitstream, forever_date); + } + + @Override + public String getEmbargoFromBitstream(Context context, Bitstream bitstream) throws SQLException { + return helper.getEmbargoFromBitstream(context, bitstream, forever_date); + } } diff --git a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java index 5f0e6d8b259..2355e25912a 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java @@ -59,7 +59,7 @@ public DefaultAccessStatusHelper() { } /** - * Look at the item's policies to determine an access status value. + * Look at the item policies to determine an access status value. * It is also considering a date threshold for embargoes and restrictions. * * If the item is null, simply returns the "unknown" value. @@ -96,7 +96,7 @@ public String getAccessStatusFromItem(Context context, Item item, Date threshold } /** - * Look at the DSpace object's policies to determine an access status value. + * Look at the DSpace object policies to determine an access status value. * * If the object is null, returns the "metadata.only" value. * If any policy attached to the object is valid for the anonymous group, @@ -170,7 +170,8 @@ private String calculateAccessStatusForDso(Context context, DSpaceObject dso, Da * * @param context the DSpace context * @param item the item to embargo - * @return an access status value + * @param threshold the embargo threshold date + * @return an embargo date */ @Override public String getEmbargoFromItem(Context context, Item item, Date threshold) @@ -211,7 +212,57 @@ public String getEmbargoFromItem(Context context, Item item, Date threshold) } /** + * Look at the policies attached directly to the bitstream to determine an access status value. + * It is also considering a date threshold for embargoes and restrictions. + * + * If the bitstream is null, simply returns the "unknown" value. + * + * @param context the DSpace context + * @param bitstream the bitstream to check for embargoes + * @param threshold the embargo threshold date + * @return an access status value + */ + @Override + public String getAccessStatusFromBitstream(Context context, Bitstream bitstream, Date threshold) + throws SQLException { + if (bitstream == null) { + return UNKNOWN; + } + return calculateAccessStatusForDso(context, bitstream, threshold); + } + + /** + * Look at the policies of the bitstream to retrieve its embargo. + * + * If the bitstream is null, simply returns no embargo date. + * + * @param context the DSpace context + * @param bitstream the bitstream to embargo + * @param threshold the embargo threshold date + * @return an embargo date + */ + @Override + public String getEmbargoFromBitstream(Context context, Bitstream bitstream, Date threshold) + throws SQLException { + if (bitstream == null) { + return null; + } + // If Bitstream status is not "embargo" then return a null embargo date. + String accessStatus = getAccessStatusFromBitstream(context, bitstream, threshold); + if (!accessStatus.equals(EMBARGO)) { + return null; + } + Date embargoDate = this.retrieveShortestEmbargo(context, bitstream); + + return embargoDate != null ? embargoDate.toString() : null; + } + + /** + * Look at the read policies of a bitstream to retrieve the shortest active embargo date. * + * @param context the DSpace context + * @param bitstream the bitstream + * @return the shortest embargo date, or null if there is none */ private Date retrieveShortestEmbargo(Context context, Bitstream bitstream) throws SQLException { Date embargoDate = null; diff --git a/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java index 2ed47bde4cd..e4bc88f0eb5 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java +++ b/dspace-api/src/main/java/org/dspace/access/status/service/AccessStatusService.java @@ -9,6 +9,7 @@ import java.sql.SQLException; +import org.dspace.content.Bitstream; import org.dspace.content.Item; import org.dspace.core.Context; @@ -54,4 +55,24 @@ public interface AccessStatusService { * @throws SQLException An exception that provides information on a database access error or other errors. */ public String getEmbargoFromItem(Context context, Item item) throws SQLException; + + /** + * Calculate the access status for a Bitstream while considering the forever embargo date threshold. + * + * @param context the DSpace context + * @param bitstream the bitstream + * @return an access status value + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public String getAccessStatus(Context context, Bitstream bitstream) throws SQLException; + + /** + * Retrieve embargo information for the bitstream + * + * @param context the DSpace context + * @param bitstream the bitstream to check for embargo information + * @return an embargo date + * @throws SQLException An exception that provides information on a database access error or other errors. + */ + public String getEmbargoFromBitstream(Context context, Bitstream bitstream) throws SQLException; } diff --git a/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java index 87127f9cf8f..8aa46820352 100644 --- a/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java +++ b/dspace-api/src/test/java/org/dspace/access/status/AccessStatusServiceTest.java @@ -8,8 +8,12 @@ package org.dspace.access.status; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import org.apache.logging.log4j.Logger; @@ -17,15 +21,20 @@ import org.dspace.access.status.factory.AccessStatusServiceFactory; import org.dspace.access.status.service.AccessStatusService; import org.dspace.authorize.AuthorizeException; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; import org.dspace.content.Collection; import org.dspace.content.Community; import org.dspace.content.Item; import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.BitstreamService; +import org.dspace.content.service.BundleService; import org.dspace.content.service.CollectionService; import org.dspace.content.service.CommunityService; import org.dspace.content.service.InstallItemService; import org.dspace.content.service.ItemService; import org.dspace.content.service.WorkspaceItemService; +import org.dspace.core.Constants; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -40,6 +49,8 @@ public class AccessStatusServiceTest extends AbstractUnitTest { private Collection collection; private Community owningCommunity; private Item item; + private Bundle bundle; + private Bitstream bitstream; protected CommunityService communityService = ContentServiceFactory.getInstance().getCommunityService(); @@ -47,6 +58,10 @@ public class AccessStatusServiceTest extends AbstractUnitTest { ContentServiceFactory.getInstance().getCollectionService(); protected ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + protected BundleService bundleService = + ContentServiceFactory.getInstance().getBundleService(); + protected BitstreamService bitstreamService = + ContentServiceFactory.getInstance().getBitstreamService(); protected WorkspaceItemService workspaceItemService = ContentServiceFactory.getInstance().getWorkspaceItemService(); protected InstallItemService installItemService = @@ -71,6 +86,10 @@ public void init() { collection = collectionService.create(context, owningCommunity); item = installItemService.installItem(context, workspaceItemService.create(context, collection, true)); + bundle = bundleService.create(context, item, Constants.CONTENT_BUNDLE_NAME); + bitstream = bitstreamService.create(context, bundle, + new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); + bitstream.setName(context, "primary"); context.restoreAuthSystemState(); } catch (AuthorizeException ex) { log.error("Authorization Error in init", ex); @@ -78,6 +97,9 @@ public void init() { } catch (SQLException ex) { log.error("SQL Error in init", ex); fail("SQL Error in init: " + ex.getMessage()); + } catch (IOException ex) { + log.error("IO Error in init", ex); + fail("IO Error in init: " + ex.getMessage()); } } @@ -92,6 +114,16 @@ public void init() { @Override public void destroy() { context.turnOffAuthorisationSystem(); + try { + bitstreamService.delete(context, bitstream); + } catch (Exception e) { + // ignore + } + try { + bundleService.delete(context, bundle); + } catch (Exception e) { + // ignore + } try { itemService.delete(context, item); } catch (Exception e) { @@ -108,6 +140,8 @@ public void destroy() { // ignore } context.restoreAuthSystemState(); + bitstream = null; + bundle = null; item = null; collection = null; owningCommunity = null; @@ -123,4 +157,22 @@ public void testGetAccessStatus() throws Exception { String status = accessStatusService.getAccessStatus(context, item); assertNotEquals("testGetAccessStatus 0", status, DefaultAccessStatusHelper.UNKNOWN); } + + @Test + public void testGetEmbargoFromItem() throws Exception { + String embargo = accessStatusService.getEmbargoFromItem(context, item); + assertNull("testGetEmbargoFromItem 0", embargo); + } + + @Test + public void testGetAccessStatusFromBitstream() throws Exception { + String status = accessStatusService.getAccessStatus(context, bitstream); + assertNotEquals("testGetAccessStatusFromBitstream 0", status, DefaultAccessStatusHelper.UNKNOWN); + } + + @Test + public void testGetEmbargoFromBitstream() throws Exception { + String embargo = accessStatusService.getEmbargoFromBitstream(context, bitstream); + assertNull("testGetEmbargoFromBitstream 0", embargo); + } } diff --git a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java index 59dfbb2095e..69d2c93dcac 100644 --- a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java +++ b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java @@ -9,6 +9,7 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; import java.io.ByteArrayInputStream; @@ -208,6 +209,18 @@ public void testWithNullItem() throws Exception { assertThat("testWithNullItem 0", status, equalTo(DefaultAccessStatusHelper.UNKNOWN)); } + /** + * Test for a null bitstream + * @throws java.lang.Exception passed through. + */ + @Test + public void testWithNullBitstream() throws Exception { + String status = helper.getAccessStatusFromBitstream(context, null, threshold); + assertThat("testWithNullBitstream 0", status, equalTo(DefaultAccessStatusHelper.UNKNOWN)); + String embargoDate = helper.getEmbargoFromBitstream(context, null, threshold); + assertNull("testWithNullBitstream 1", embargoDate); + } + /** * Test for an item with no bundle * @throws java.lang.Exception passed through. @@ -246,6 +259,10 @@ public void testWithBitstream() throws Exception { context.restoreAuthSystemState(); String status = helper.getAccessStatusFromItem(context, itemWithBitstream, threshold); assertThat("testWithBitstream 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + String bitstreamStatus = helper.getAccessStatusFromBitstream(context, bitstream, threshold); + assertThat("testWithBitstream 1", bitstreamStatus, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); + String bitstreamEmbargoDate = helper.getEmbargoFromBitstream(context, bitstream, threshold); + assertNull("testWithBitstream 2", bitstreamEmbargoDate); } /** @@ -274,6 +291,10 @@ public void testWithEmbargo() throws Exception { assertThat("testWithEmbargo 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); String embargoDate = helper.getEmbargoFromItem(context, itemWithEmbargo, threshold); assertThat("testWithEmbargo 1", embargoDate, equalTo(policy.getStartDate().toString())); + String bitstreamStatus = helper.getAccessStatusFromBitstream(context, bitstream, threshold); + assertThat("testWithEmbargo 2", bitstreamStatus, equalTo(DefaultAccessStatusHelper.EMBARGO)); + String bitstreamEmbargoDate = helper.getEmbargoFromBitstream(context, bitstream, threshold); + assertThat("testWithEmbargo 3", bitstreamEmbargoDate, equalTo(policy.getStartDate().toString())); } /** @@ -300,6 +321,10 @@ public void testWithDateRestriction() throws Exception { context.restoreAuthSystemState(); String status = helper.getAccessStatusFromItem(context, itemWithDateRestriction, threshold); assertThat("testWithDateRestriction 0", status, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + String bitstreamStatus = helper.getAccessStatusFromBitstream(context, bitstream, threshold); + assertThat("testWithDateRestriction 1", bitstreamStatus, equalTo(DefaultAccessStatusHelper.RESTRICTED)); + String bitstreamEmbargoDate = helper.getEmbargoFromBitstream(context, bitstream, threshold); + assertNull("testWithDateRestriction 2", bitstreamEmbargoDate); } /** @@ -371,7 +396,7 @@ public void testWithPrimaryAndMultipleBitstreams() throws Exception { context.turnOffAuthorisationSystem(); Bundle bundle = bundleService.create(context, itemWithPrimaryAndMultipleBitstreams, Constants.CONTENT_BUNDLE_NAME); - bitstreamService.create(context, bundle, + Bitstream otherBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); Bitstream primaryBitstream = bitstreamService.create(context, bundle, new ByteArrayInputStream("1".getBytes(StandardCharsets.UTF_8))); @@ -390,6 +415,12 @@ public void testWithPrimaryAndMultipleBitstreams() throws Exception { assertThat("testWithPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); String embargoDate = helper.getEmbargoFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); assertThat("testWithPrimaryAndMultipleBitstreams 1", embargoDate, equalTo(policy.getStartDate().toString())); + String primaryBitstreamStatus = helper.getAccessStatusFromBitstream(context, primaryBitstream, threshold); + assertThat("testWithPrimaryAndMultipleBitstreams 2", primaryBitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); + String otherBitstreamStatus = helper.getAccessStatusFromBitstream(context, otherBitstream, threshold); + assertThat("testWithPrimaryAndMultipleBitstreams 3", otherBitstreamStatus, + equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); } /** @@ -420,5 +451,8 @@ public void testWithNoPrimaryAndMultipleBitstreams() throws Exception { assertThat("testWithNoPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.OPEN_ACCESS)); String embargoDate = helper.getEmbargoFromItem(context, itemWithEmbargo, threshold); assertThat("testWithNoPrimaryAndMultipleBitstreams 1", embargoDate, equalTo(null)); + String otherBitstreamStatus = helper.getAccessStatusFromBitstream(context, anotherBitstream, threshold); + assertThat("testWithNoPrimaryAndMultipleBitstreams 2", otherBitstreamStatus, + equalTo(DefaultAccessStatusHelper.EMBARGO)); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java index c7dc2d11985..a2b82b8fc5a 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/AccessStatusRest.java @@ -18,6 +18,7 @@ public class AccessStatusRest implements RestModel { public static final String NAME = "accessStatus"; String status; + String embargoDate; @Override @JsonProperty(access = Access.READ_ONLY) @@ -33,10 +34,12 @@ public String getTypePlural() { public AccessStatusRest() { setStatus(null); + setEmbargoDate(null); } public AccessStatusRest(String status) { setStatus(status); + setEmbargoDate(null); } public String getStatus() { @@ -46,4 +49,12 @@ public String getStatus() { public void setStatus(String status) { this.status = status; } + + public String getEmbargoDate() { + return embargoDate; + } + + public void setEmbargoDate(String embargoDate) { + this.embargoDate = embargoDate; + } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java index a1c3156a01a..3f64acfb1c1 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/BitstreamRest.java @@ -31,6 +31,10 @@ @LinkRest( name = BitstreamRest.CHECKSUM, method = "getChecksum" + ), + @LinkRest( + name = BitstreamRest.ACCESS_STATUS, + method = "getAccessStatus" ) }) public class BitstreamRest extends DSpaceObjectRest { @@ -42,6 +46,7 @@ public class BitstreamRest extends DSpaceObjectRest { public static final String FORMAT = "format"; public static final String THUMBNAIL = "thumbnail"; public static final String CHECKSUM = "checksum"; + public static final String ACCESS_STATUS = "accessStatus"; private String bundleName; diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java new file mode 100644 index 00000000000..90661a57a57 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java @@ -0,0 +1,67 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +package org.dspace.app.rest.repository; + +import java.sql.SQLException; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; + +import org.dspace.access.status.DefaultAccessStatusHelper; +import org.dspace.access.status.service.AccessStatusService; +import org.dspace.app.rest.model.AccessStatusRest; +import org.dspace.app.rest.model.BitstreamRest; +import org.dspace.app.rest.projection.Projection; +import org.dspace.content.Bitstream; +import org.dspace.content.service.BitstreamService; +import org.dspace.core.Context; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.rest.webmvc.ResourceNotFoundException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +/** + * Link repository for calculating the access status of a Bitstream, + * including the embargo date. + */ +@Component(BitstreamRest.CATEGORY + "." + BitstreamRest.NAME + "." + BitstreamRest.ACCESS_STATUS) +public class BitstreamAccessStatusLinkRepository extends AbstractDSpaceRestRepository + implements LinkRestRepository { + + @Autowired + BitstreamService bitstreamService; + + @Autowired + AccessStatusService accessStatusService; + + @PreAuthorize("hasPermission(#bitstreamId, 'BITSTREAM', 'METADATA_READ')") + public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, + UUID bitstreamId, + @Nullable Pageable optionalPageable, + Projection projection) { + try { + Context context = obtainContext(); + Bitstream bitstream = bitstreamService.find(context, bitstreamId); + if (bitstream == null) { + throw new ResourceNotFoundException("No such bitstream: " + bitstreamId); + } + AccessStatusRest accessStatusRest = new AccessStatusRest(); + String status = accessStatusService.getAccessStatus(context, bitstream); + if (status.equals(DefaultAccessStatusHelper.EMBARGO)) { + String embargoDate = accessStatusService.getEmbargoFromBitstream(context, bitstream); + accessStatusRest.setEmbargoDate(embargoDate); + } + accessStatusRest.setStatus(status); + return accessStatusRest; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java index b2660f51e09..30a73f64ead 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java @@ -13,6 +13,7 @@ import javax.annotation.Nullable; import javax.servlet.http.HttpServletRequest; +import org.dspace.access.status.DefaultAccessStatusHelper; import org.dspace.access.status.service.AccessStatusService; import org.dspace.app.rest.model.AccessStatusRest; import org.dspace.app.rest.model.ItemRest; @@ -52,6 +53,10 @@ public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, } AccessStatusRest accessStatusRest = new AccessStatusRest(); String accessStatus = accessStatusService.getAccessStatus(context, item); + if (accessStatus.equals(DefaultAccessStatusHelper.EMBARGO)) { + String embargoDate = accessStatusService.getEmbargoFromItem(context, item); + accessStatusRest.setEmbargoDate(embargoDate); + } accessStatusRest.setStatus(accessStatus); return accessStatusRest; } catch (SQLException e) { diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java index ecfe933fd92..e565fe1ea3f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/BitstreamRestRepositoryIT.java @@ -33,6 +33,7 @@ import org.apache.commons.codec.CharEncoding; import org.apache.commons.io.IOUtils; +import org.dspace.access.status.DefaultAccessStatusHelper; import org.dspace.app.rest.matcher.BitstreamFormatMatcher; import org.dspace.app.rest.matcher.BitstreamMatcher; import org.dspace.app.rest.matcher.BundleMatcher; @@ -2928,6 +2929,53 @@ public void deleteBitstreamsInBulk_communityAdmin() throws Exception { .andExpect(status().isNoContent()); } + @Test + public void findAccessStatusForBitstreamBadRequestTest() throws Exception { + getClient().perform(get("/api/core/bitstreams/{uuid}/accessStatus", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + public void findAccessStatusForBitstreamNotFoundTest() throws Exception { + // Unlike the item-level accessStatus link (gated on plain 'READ'), the bitstream-level link is + // gated on 'METADATA_READ' (see BitstreamMetadataReadPermissionEvaluatorPlugin), whose evaluator + // denies permission outright when the target bitstream cannot be resolved, so an anonymous request + // never reaches the controller's not-found check and instead gets a 401. + UUID fakeUUID = UUID.randomUUID(); + getClient().perform(get("/api/core/bitstreams/{uuid}/accessStatus", fakeUUID)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void findAccessStatusForBitstreamTest() throws Exception { + context.turnOffAuthorisationSystem(); + parentCommunity = CommunityBuilder.createCommunity(context) + .withName("Parent Community") + .build(); + Collection col1 = CollectionBuilder.createCollection(context, parentCommunity) + .withName("Collection 1") + .build(); + Item publicItem1 = ItemBuilder.createItem(context, col1) + .withTitle("Test item 1") + .build(); + String bitstreamContent = "ThisIsSomeDummyText"; + Bitstream bitstream = null; + try (InputStream is = IOUtils.toInputStream(bitstreamContent, CharEncoding.UTF_8)) { + bitstream = BitstreamBuilder.createBitstream(context, publicItem1, is) + .withName("Bitstream") + .withDescription("Description") + .withMimeType("text/plain") + .build(); + } + context.restoreAuthSystemState(); + + // Bitstream access status should still be accessible by anonymous request + getClient().perform(get("/api/core/bitstreams/" + bitstream.getID() + "/accessStatus")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.type", is("accessStatus"))) + .andExpect(jsonPath("$.status", is(DefaultAccessStatusHelper.OPEN_ACCESS))); + } + public boolean bitstreamExists(String token, Bitstream ...bitstreams) throws Exception { for (Bitstream bitstream : bitstreams) { if (getClient(token).perform(get("/api/core/bitstreams/" + bitstream.getID())) diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java index 9c9c0513c18..97746e75140 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/matcher/BitstreamMatcher.java @@ -103,7 +103,8 @@ public static Matcher matchFullEmbeds() { "bundle", "format", "thumbnail", - "checksum" + "checksum", + "accessStatus" ); } @@ -117,7 +118,8 @@ public static Matcher matchLinks(UUID uuid) { "format", "self", "thumbnail", - "checksum" + "checksum", + "accessStatus" ); } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java index 7dfe3e69e0e..471e5d3704b 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/model/AccessStatusRestTest.java @@ -15,7 +15,7 @@ import org.junit.Test; /** - * Test the AccessStatusRestTest class + * Test the AccessStatusRest class */ public class AccessStatusRestTest { @@ -36,4 +36,15 @@ public void testAccessStatusIsNotNullAfterStatusSet() throws Exception { accessStatusRest.setStatus(DefaultAccessStatusHelper.UNKNOWN); assertNotNull(accessStatusRest.getStatus()); } + + @Test + public void testEmbargoDateIsNullBeforeEmbargoDateSet() throws Exception { + assertNull(accessStatusRest.getEmbargoDate()); + } + + @Test + public void testEmbargoDateIsNotNullAfterEmbargoDateSet() throws Exception { + accessStatusRest.setEmbargoDate("2050-01-01"); + assertNotNull(accessStatusRest.getEmbargoDate()); + } } From 0edf9d72dbcfa7e50f16e6b735b1952762682d3d Mon Sep 17 00:00:00 2001 From: Matus Beke Date: Tue, 21 Jul 2026 10:04:15 +0200 Subject: [PATCH 2/2] Fix embargoDate REST format and null-safe EMBARGO comparison getEmbargoFromItem/getEmbargoFromBitstream returned Date#toString(), a locale/timezone-dependent format (e.g. "Tue Jan 01 00:00:00 UTC 2050"), instead of the plain ISO yyyy-MM-dd format used elsewhere in the REST API (see ResourcePolicyRest / ResourcePolicyRestRepositoryIT). Since this PR is what first exposes embargoDate over REST, format it consistently so the frontend (dspace-angular#1378) can reliably parse it. Updated DefaultAccessStatusHelperTest assertions to match. Also swap the EMBARGO.equals(status) comparisons in Item/BitstreamAccessStatusLinkRepository to be constant-first, so a null status from a custom AccessStatusHelper plugin can't NPE. Addresses Copilot review comments on PR #1377. Co-Authored-By: Claude Sonnet 5 --- .../status/DefaultAccessStatusHelper.java | 22 +++++++++++++++++-- .../status/DefaultAccessStatusHelperTest.java | 10 ++++++--- .../BitstreamAccessStatusLinkRepository.java | 2 +- .../ItemAccessStatusLinkRepository.java | 2 +- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java index 2355e25912a..81844f5017e 100644 --- a/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java +++ b/dspace-api/src/main/java/org/dspace/access/status/DefaultAccessStatusHelper.java @@ -8,6 +8,7 @@ package org.dspace.access.status; import java.sql.SQLException; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Objects; @@ -47,6 +48,11 @@ public class DefaultAccessStatusHelper implements AccessStatusHelper { public static final String RESTRICTED = "restricted"; public static final String UNKNOWN = "unknown"; + // REST date fields use a plain ISO calendar date (e.g. "2050-01-01"), matching the format used + // elsewhere in the REST API (see ResourcePolicyRest). Date#toString() is locale/timezone-dependent + // and must not be used for values exposed over REST. + private static final String REST_DATE_FORMAT = "yyyy-MM-dd"; + protected ItemService itemService = ContentServiceFactory.getInstance().getItemService(); protected ResourcePolicyService resourcePolicyService = @@ -208,7 +214,7 @@ public String getEmbargoFromItem(Context context, Item item, Date threshold) embargoDate = this.retrieveShortestEmbargo(context, bitstream); - return embargoDate != null ? embargoDate.toString() : null; + return formatEmbargoDate(embargoDate); } /** @@ -254,7 +260,19 @@ public String getEmbargoFromBitstream(Context context, Bitstream bitstream, Date } Date embargoDate = this.retrieveShortestEmbargo(context, bitstream); - return embargoDate != null ? embargoDate.toString() : null; + return formatEmbargoDate(embargoDate); + } + + /** + * Format an embargo date for REST exposure as a plain ISO calendar date (yyyy-MM-dd), + * matching the format used elsewhere in the REST API. SimpleDateFormat is not thread-safe, + * so a new instance is created per call. + * + * @param date the date to format, may be null + * @return the formatted date, or null if the given date is null + */ + private String formatEmbargoDate(Date date) { + return date != null ? new SimpleDateFormat(REST_DATE_FORMAT).format(date) : null; } /** diff --git a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java index 69d2c93dcac..2f91a8db1bd 100644 --- a/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java +++ b/dspace-api/src/test/java/org/dspace/access/status/DefaultAccessStatusHelperTest.java @@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -290,11 +291,13 @@ public void testWithEmbargo() throws Exception { String status = helper.getAccessStatusFromItem(context, itemWithEmbargo, threshold); assertThat("testWithEmbargo 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); String embargoDate = helper.getEmbargoFromItem(context, itemWithEmbargo, threshold); - assertThat("testWithEmbargo 1", embargoDate, equalTo(policy.getStartDate().toString())); + assertThat("testWithEmbargo 1", embargoDate, + equalTo(new SimpleDateFormat("yyyy-MM-dd").format(policy.getStartDate()))); String bitstreamStatus = helper.getAccessStatusFromBitstream(context, bitstream, threshold); assertThat("testWithEmbargo 2", bitstreamStatus, equalTo(DefaultAccessStatusHelper.EMBARGO)); String bitstreamEmbargoDate = helper.getEmbargoFromBitstream(context, bitstream, threshold); - assertThat("testWithEmbargo 3", bitstreamEmbargoDate, equalTo(policy.getStartDate().toString())); + assertThat("testWithEmbargo 3", bitstreamEmbargoDate, + equalTo(new SimpleDateFormat("yyyy-MM-dd").format(policy.getStartDate()))); } /** @@ -414,7 +417,8 @@ public void testWithPrimaryAndMultipleBitstreams() throws Exception { String status = helper.getAccessStatusFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); assertThat("testWithPrimaryAndMultipleBitstreams 0", status, equalTo(DefaultAccessStatusHelper.EMBARGO)); String embargoDate = helper.getEmbargoFromItem(context, itemWithPrimaryAndMultipleBitstreams, threshold); - assertThat("testWithPrimaryAndMultipleBitstreams 1", embargoDate, equalTo(policy.getStartDate().toString())); + assertThat("testWithPrimaryAndMultipleBitstreams 1", embargoDate, + equalTo(new SimpleDateFormat("yyyy-MM-dd").format(policy.getStartDate()))); String primaryBitstreamStatus = helper.getAccessStatusFromBitstream(context, primaryBitstream, threshold); assertThat("testWithPrimaryAndMultipleBitstreams 2", primaryBitstreamStatus, equalTo(DefaultAccessStatusHelper.EMBARGO)); diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java index 90661a57a57..5c85cf4c7d6 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/BitstreamAccessStatusLinkRepository.java @@ -54,7 +54,7 @@ public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, } AccessStatusRest accessStatusRest = new AccessStatusRest(); String status = accessStatusService.getAccessStatus(context, bitstream); - if (status.equals(DefaultAccessStatusHelper.EMBARGO)) { + if (DefaultAccessStatusHelper.EMBARGO.equals(status)) { String embargoDate = accessStatusService.getEmbargoFromBitstream(context, bitstream); accessStatusRest.setEmbargoDate(embargoDate); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java index 30a73f64ead..372fc16579d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/ItemAccessStatusLinkRepository.java @@ -53,7 +53,7 @@ public AccessStatusRest getAccessStatus(@Nullable HttpServletRequest request, } AccessStatusRest accessStatusRest = new AccessStatusRest(); String accessStatus = accessStatusService.getAccessStatus(context, item); - if (accessStatus.equals(DefaultAccessStatusHelper.EMBARGO)) { + if (DefaultAccessStatusHelper.EMBARGO.equals(accessStatus)) { String embargoDate = accessStatusService.getEmbargoFromItem(context, item); accessStatusRest.setEmbargoDate(embargoDate); }