diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java new file mode 100644 index 000000000000..a6bdfa803a96 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java @@ -0,0 +1,61 @@ +/** + * 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.model.step; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; + +/** + * Java Bean to expose the CLARIN license section during in progress submission. + * + * @author Milan Majchrak (milan.majchrak at dataquest.sk) + */ +public class ClarinDataLicense implements SectionData { + + private String name; + + @JsonProperty(access = Access.READ_ONLY) + private String definition; + + @JsonProperty(access = Access.READ_ONLY) + private String label; + + private boolean granted = false; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDefinition() { + return definition; + } + + public void setDefinition(String definition) { + this.definition = definition; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public boolean isGranted() { + return granted; + } + + public void setGranted(boolean granted) { + this.granted = granted; + } +} \ No newline at end of file diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index a229d8b8c8c7..b4f29213af85 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -44,6 +44,7 @@ import org.dspace.app.rest.repository.handler.service.UriListHandlerService; import org.dspace.app.rest.submit.SubmissionService; import org.dspace.app.rest.submit.UploadableStep; +import org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils; import org.dspace.app.rest.utils.BigMultipartFile; import org.dspace.app.rest.utils.Utils; import org.dspace.app.util.SubmissionConfig; @@ -51,14 +52,11 @@ import org.dspace.app.util.SubmissionStepConfig; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; -import org.dspace.content.Bitstream; -import org.dspace.content.Bundle; import org.dspace.content.Collection; import org.dspace.content.Item; import org.dspace.content.LicenseUtils; import org.dspace.content.MetadataValue; import org.dspace.content.WorkspaceItem; -import org.dspace.content.clarin.ClarinLicense; import org.dspace.content.service.BitstreamFormatService; import org.dspace.content.service.BitstreamService; import org.dspace.content.service.CollectionService; @@ -532,12 +530,13 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper // Get item Item item = source.getItem(); if (Objects.isNull(item)) { - // add log + log.warn("Cannot maintain CLARIN licenses: workspace item {} has no underlying item.", source.getID()); return; } // Get value from operation if (!(op instanceof ReplaceOperation)) { - // add log + log.warn("Ignoring non-replace operation '{}' on license patch path for workspace item {}.", + op.getOp(), source.getID()); return; } @@ -555,53 +554,13 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper clarinLicenseName = jsonNodeValue.asText(); } - // Get clarin license by definition - ClarinLicense clarinLicense = clarinLicenseService.findByName(context, clarinLicenseName); - if (StringUtils.isNotBlank(clarinLicenseName) && Objects.isNull(clarinLicense)) { - throw new ClarinLicenseNotFoundException("Cannot patch workspace item with id: " + source.getID() + "," + - " because the clarin license with name: " + clarinLicenseName + " isn't supported in" + - " the CLARIN/DSpace"); - } - - // Clear the license metadata from the item - clarinLicenseService.clearLicenseMetadataFromItem(context, item); - - // Detach the clarin licenses from the uploaded bitstreams - List bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME); - for (Bundle bundle : bundles) { - List bitstreamList = bundle.getBitstreams(); - for (Bitstream bitstream : bitstreamList) { - // in case bitstream ID exists in license table for some reason .. just remove it - this.clarinLicenseResourceMappingService.detachLicenses(context, bitstream); - } - } - - // Save changes to database - itemService.update(context, item); - - if (Objects.isNull(clarinLicense)) { - log.info("The clarin license is null so all item metadata for license was cleared and the" + - "licenses was detached."); - return; - } - - // If the clarin license is not null that means some clarin license was updated and accepted - // Attach the new clarin license to every bitstream and add clarin license values to the item metadata. - - // update item metadata with license data - clarinLicenseService.addLicenseMetadataToItem(context, clarinLicense, item); - - // Attach the clarin license to the bitstreams - for (Bundle bundle : bundles) { - List bitstreamList = bundle.getBitstreams(); - for (Bitstream bitstream : bitstreamList) { - // in case bitstream ID exists in license table for some reason .. just remove it - this.clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream); - } + // Delegate to the shared helper so the legacy `/license` path and the + // section path `/sections/clarin-license/select` apply the same logic. + try { + ClarinLicenseSubmissionUtils.applyLicense(context, item, clarinLicenseName); + } catch (ClarinLicenseNotFoundException ex) { + throw new UnprocessableEntityException(ex.getMessage(), ex); } - - // Save changes to database - itemService.update(context, item); } private void grantDistributionLicense(Context context, WorkspaceItem source, Operation op) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index 84babbbd7b27..b3e315af445e 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -7,53 +7,71 @@ */ package org.dspace.app.rest.submit.step; +import java.util.List; import javax.servlet.http.HttpServletRequest; -import org.atteo.evo.inflector.English; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.app.rest.exception.UnprocessableEntityException; -import org.dspace.app.rest.model.BitstreamRest; +import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; -import org.dspace.app.rest.model.step.DataLicense; +import org.dspace.app.rest.model.patch.ReplaceOperation; +import org.dspace.app.rest.model.step.ClarinDataLicense; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; -import org.dspace.app.rest.submit.factory.PatchOperationFactory; -import org.dspace.app.rest.submit.factory.impl.PatchOperation; import org.dspace.app.util.SubmissionStepConfig; -import org.dspace.content.Bitstream; import org.dspace.content.InProgressSubmission; -import org.dspace.core.Constants; +import org.dspace.content.Item; +import org.dspace.content.MetadataValue; import org.dspace.core.Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Clarin License Resource License step for DSpace Spring Rest. This Step will show license selector - * where the user could choose license for the bitstream. + * Submission step exposing the CLARIN resource license selected for the + * in-progress submission. Data is sourced from the item's {@code dc.rights*} + * metadata; the selection is updated via a section-scoped patch + * {@code /sections/clarin-license/select}. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) - * - * This class is inspired by the class LicenseStep created by - * @author Luigi Andrea Pascarelli (luigiandrea.pascarelli at 4science.it) - * */ public class ClarinLicenseResourceStep extends AbstractProcessingStep { - private static final String DCTERMS_RIGHTSDATE = "dcterms.accessRights"; + private static final Logger log = LoggerFactory.getLogger(ClarinLicenseResourceStep.class); + + /** + * Sub-path of the section patch used to select a CLARIN license by name, + * e.g. {@code /sections/clarin-license/select}. + */ + private static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; @Override - public DataLicense getData(SubmissionService submissionService, InProgressSubmission obj, - SubmissionStepConfig config) - throws Exception { - DataLicense result = new DataLicense(); - Bitstream bitstream = bitstreamService - .getBitstreamByName(obj.getItem(), Constants.LICENSE_BUNDLE_NAME, Constants.LICENSE_BITSTREAM_NAME); - if (bitstream != null) { - String acceptanceDate = bitstreamService.getMetadata(bitstream, DCTERMS_RIGHTSDATE); - result.setAcceptanceDate(acceptanceDate); - result.setUrl( - configurationService.getProperty("dspace.server.url") - + "/api/" + BitstreamRest.CATEGORY + "/" + English - .plural(BitstreamRest.NAME) + "/" + bitstream.getID() + "/content"); - result.setGranted(true); + public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj, + SubmissionStepConfig config) { + ClarinDataLicense result = new ClarinDataLicense(); + Item item = obj.getItem(); + if (item == null) { + return result; + } + + List name = itemService.getMetadataByMetadataString(item, "dc.rights"); + List uri = itemService.getMetadataByMetadataString(item, "dc.rights.uri"); + List label = itemService.getMetadataByMetadataString(item, "dc.rights.label"); + + if (CollectionUtils.isNotEmpty(name)) { + result.setName(name.get(0).getValue()); + } + if (CollectionUtils.isNotEmpty(uri)) { + result.setDefinition(uri.get(0).getValue()); + } + if (CollectionUtils.isNotEmpty(label)) { + result.setLabel(label.get(0).getValue()); } + result.setGranted(CollectionUtils.isNotEmpty(name) + && CollectionUtils.isNotEmpty(uri) + && CollectionUtils.isNotEmpty(label)); return result; } @@ -61,14 +79,88 @@ public DataLicense getData(SubmissionService submissionService, InProgressSubmis public void doPatchProcessing(Context context, HttpServletRequest currentRequest, InProgressSubmission source, Operation op, SubmissionStepConfig stepConf) throws Exception { - if (op.getPath().endsWith(LICENSE_STEP_OPERATION_ENTRY)) { + String path = op.getPath(); - PatchOperation patchOperation = new PatchOperationFactory() - .instanceOf(LICENSE_STEP_OPERATION_ENTRY, op.getOp()); - patchOperation.perform(context, currentRequest, source, op); + if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY)) { + if (!(op instanceof ReplaceOperation)) { + throw new UnprocessableEntityException( + "The operation '" + op.getOp() + "' is not supported for path " + path); + } + String licenseName = extractLicenseName(op); + // Section endpoint: a missing or blank license name is treated as a + // client error (422). The legacy `/license` path in + // WorkspaceItemRestRepository intentionally treats a blank value as + // "clear the current license" for backwards compatibility. + if (StringUtils.isBlank(licenseName)) { + throw new UnprocessableEntityException( + "The patch value for path " + path + " must contain a non-empty license name."); + } + try { + ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); + } catch (ClarinLicenseNotFoundException ex) { + // Surface invalid client input as 422 instead of leaking as 500. + throw new UnprocessableEntityException(ex.getMessage(), ex); + } + return; + } + + if (path.endsWith(LICENSE_STEP_OPERATION_ENTRY)) { + // `granted` patches are a no-op on this section; kept for older clients. + log.info("Ignoring legacy '{}/granted' patch on the CLARIN license section.", stepConf.getId()); + return; + } - } else { - throw new UnprocessableEntityException("The path " + op.getPath() + " cannot be patched"); + throw new UnprocessableEntityException("The path " + path + " cannot be patched"); + } + + /** + * Extract the CLARIN license name from a JSON Patch {@link Operation}. + *

+ * The submission API receives section updates as JSON Patch operations + * (see {@code /sections/clarin-license/select}). The {@code value} field + * of such an operation is not strongly typed: depending on the request + * shape and how the JSON Patch payload was parsed upstream, it can arrive + * as: + *

    + *
  • a plain {@link String}, e.g. {@code "value": "CC-BY"};
  • + *
  • a {@link JsonValueEvaluator} wrapping a {@link JsonNode}, when the + * payload is sent as a JSON object such as + * {@code "value": { "value": "CC-BY" }} or as a bare textual node;
  • + *
  • {@code null} when the client omitted the value entirely.
  • + *
+ * This helper normalizes those cases into a single {@code String} license + * name (or {@code null} if no usable value is present), so the rest of the + * step can call {@link ClarinLicenseSubmissionUtils#applyLicense} with a + * simple value and treat missing input as a client error. + * + * @param op the JSON Patch operation targeting the license {@code select} path + * @return the license name extracted from the operation value, or {@code null} + * if the operation has no usable value (missing, null, or of an + * unsupported type) + */ + private String extractLicenseName(Operation op) { + Object value = op.getValue(); + if (value == null) { + return null; + } + if (value instanceof String) { + return (String) value; + } + if (value instanceof JsonValueEvaluator) { + JsonNode valueNode = ((JsonValueEvaluator) value).getValueNode(); + if (valueNode == null) { + return null; + } + JsonNode inner = valueNode.get("value"); + if (inner != null) { + return inner.asText(); + } + if (valueNode.isTextual()) { + return valueNode.asText(); + } } + log.warn("Unsupported Operation value type for license name extraction: {}", + value.getClass().getName()); + return null; } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java new file mode 100644 index 000000000000..ccc6e886408f --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java @@ -0,0 +1,122 @@ +/** + * 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.submit.step; + +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Item; +import org.dspace.content.clarin.ClarinLicense; +import org.dspace.content.factory.ClarinServiceFactory; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService; +import org.dspace.content.service.clarin.ClarinLicenseService; +import org.dspace.core.Constants; +import org.dspace.core.Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared logic for applying a CLARIN license selection to an in-progress + * submission item. Used by both the legacy top-level {@code /license} patch + * path in {@code WorkspaceItemRestRepository} and the section-scoped patch + * path handled by {@link ClarinLicenseResourceStep}. + * + * @author Milan Majchrak (milan.majchrak at dataquest.sk) + */ +public final class ClarinLicenseSubmissionUtils { + + private static final Logger log = LoggerFactory.getLogger(ClarinLicenseSubmissionUtils.class); + + private ClarinLicenseSubmissionUtils() { + } + + /** + * Apply the given CLARIN license selection to the supplied item. + *
    + *
  • Always clears the previously stored {@code dc.rights*} + * metadata and detaches any existing CLARIN license mapping + * from the bitstreams in the {@code ORIGINAL} bundle.
  • + *
  • If a non-blank {@code clarinLicenseName} is provided and + * resolves to an existing {@link ClarinLicense} the new + * license metadata is added to the item and the license is + * attached to every bitstream in the {@code ORIGINAL} bundle. + *
  • + *
+ * + * @param context DSpace context + * @param item the item being submitted + * @param clarinLicenseName name of the CLARIN license to apply, or + * {@code null}/empty to clear the current + * selection + * @throws SQLException on database errors + * @throws AuthorizeException on authorization errors + * @throws ClarinLicenseNotFoundException if a non-empty name was + * supplied but no matching CLARIN license exists + */ + public static void applyLicense(Context context, Item item, String clarinLicenseName) + throws SQLException, AuthorizeException { + if (Objects.isNull(item)) { + log.info("Cannot apply CLARIN license, item is null."); + return; + } + + ClarinLicenseService clarinLicenseService = + ClarinServiceFactory.getInstance().getClarinLicenseService(); + ClarinLicenseResourceMappingService clarinLicenseResourceMappingService = + ClarinServiceFactory.getInstance().getClarinLicenseResourceMappingService(); + ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + + // Resolve license up-front so we fail before mutating state + ClarinLicense clarinLicense = null; + if (StringUtils.isNotBlank(clarinLicenseName)) { + clarinLicense = clarinLicenseService.findByName(context, clarinLicenseName); + if (Objects.isNull(clarinLicense)) { + throw new ClarinLicenseNotFoundException( + "The CLARIN license with name: " + clarinLicenseName + + " isn't supported in the CLARIN/DSpace"); + } + } + + // Clear existing license metadata from the item + clarinLicenseService.clearLicenseMetadataFromItem(context, item); + + // Detach existing CLARIN licenses from the uploaded bitstreams + List bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME); + for (Bundle bundle : bundles) { + for (Bitstream bitstream : bundle.getBitstreams()) { + clarinLicenseResourceMappingService.detachLicenses(context, bitstream); + } + } + + if (Objects.isNull(clarinLicense)) { + // Persist the cleared metadata state and stop. + itemService.update(context, item); + log.info("CLARIN license selection cleared on item {}.", item.getID()); + return; + } + + // Attach the new CLARIN license to every bitstream and add metadata + clarinLicenseService.addLicenseMetadataToItem(context, clarinLicense, item); + for (Bundle bundle : bundles) { + for (Bitstream bitstream : bundle.getBitstreams()) { + clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream); + } + } + // Persist all metadata changes in a single update at the end. + itemService.update(context, item); + log.info("CLARIN license '{}' applied to item {}.", clarinLicenseName, item.getID()); + } +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index fdc406c5b67f..aae481283540 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -820,6 +820,241 @@ public void updateClarinLicenseInWI() throws Exception { .andExpect(jsonPath("$.bitstreams", is(1))); } + /** + * Applying the CLARIN license via the section-scoped path + * `/sections/clarin-license/select` must work exactly like the legacy + * top-level `/license` path. + */ + @Test + public void addClarinLicenseViaSectionPatch() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Test Section Clarin License"; + ClarinLicense clarinLicense = createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + List replaceOperations = new ArrayList(); + Map licenseReplaceOpValue = new HashMap(); + licenseReplaceOpValue.put("value", clarinLicenseName); + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/select", + licenseReplaceOpValue)); + String updateBody = getPatchContent(replaceOperations); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(updateBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // Item metadata `dc.rights` must contain the CLARIN license name + assertClarinLicenseMetadata(witem, "dc", "rights", null, clarinLicenseName, false); + + // Bitstream must be attached to the CLARIN license + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bitstreams", is(1))); + } + + /** + * After applying a CLARIN license, GET on the workspace item must expose + * distinct payloads for the standard `license` section + * (CC license: url/acceptanceDate/granted) and the `clarin-license` + * section (name/definition/label/granted from `dc.rights*`). + */ + @Test + public void getWorkspaceItemReturnsDistinctLicenseSections() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Distinct Sections Clarin License"; + createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + // Apply the CLARIN license through the section-scoped path + List replaceOperations = new ArrayList(); + Map licenseReplaceOpValue = new HashMap(); + licenseReplaceOpValue.put("value", clarinLicenseName); + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/select", + licenseReplaceOpValue)); + String updateBody = getPatchContent(replaceOperations); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(updateBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // GET the workspace item and assert the two sections are distinct + getClient(tokenAdmin).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + // clarin-license section reflects CLARIN-specific fields + .andExpect(jsonPath("$.sections['clarin-license'].name", is(clarinLicenseName))) + .andExpect(jsonPath("$.sections['clarin-license'].definition").isNotEmpty()) + .andExpect(jsonPath("$.sections['clarin-license'].label").isNotEmpty()) + .andExpect(jsonPath("$.sections['clarin-license'].granted", is(true))) + .andExpect(jsonPath("$.sections.license.granted", is(false))) + .andExpect(jsonPath("$.sections.license.acceptanceDate").isEmpty()) + .andExpect(jsonPath("$.sections.license.url").isEmpty()); + } + + /** + * PATCH on `/sections/clarin-license/select` with an empty value must clear + * the previously selected license: `dc.rights*` metadata is removed and the + * license is detached from the uploaded bitstream. + */ + @Test + public void patchSelectWithEmptyValueClearsLicense() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Empty Value Clarin License"; + ClarinLicense clarinLicense = createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + // First select the license through the new section path + List ops = new ArrayList(); + Map selectValue = new HashMap(); + selectValue.put("value", clarinLicenseName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", selectValue)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + assertClarinLicenseMetadata(witem, "dc", "rights", null, clarinLicenseName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + + // Now clear the selection with an empty value + ops.clear(); + Map emptyValue = new HashMap(); + emptyValue.put("value", ""); + ops.add(new ReplaceOperation("/sections/clarin-license/select", emptyValue)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // Item metadata cleared and license detached from bitstream + assertClarinLicenseMetadata(witem, "dc", "rights", null, null, true); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(jsonPath("$.bitstreams", is(0))); + } + + /** + * Two consecutive PATCHes on `/sections/clarin-license/select` must replace + * the previously selected license: `dc.rights` reflects the latest name and + * the bitstream is moved from the first license to the second one. + */ + @Test + public void patchSelectReplacesPreviousLicense() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String firstName = "First Clarin License"; + String secondName = "Second Clarin License"; + ClarinLicense first = createClarinLicense(firstName, "Def1", "Info1", Confirmation.NOT_REQUIRED); + ClarinLicense second = createClarinLicense(secondName, "Def2", "Info2", Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + // Select first license + List ops = new ArrayList(); + Map v1 = new HashMap(); + v1.put("value", firstName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v1)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + assertClarinLicenseMetadata(witem, "dc", "rights", null, firstName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + first.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + + // Replace with second license through the same section path + ops.clear(); + Map v2 = new HashMap(); + v2.put("value", secondName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v2)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + assertClarinLicenseMetadata(witem, "dc", "rights", null, secondName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + first.getID())) + .andExpect(jsonPath("$.bitstreams", is(0))); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + second.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + } + + /** + * PATCH on `/sections/clarin-license/select` with a name that does not + * resolve to an existing CLARIN license must fail with 422 and must not + * mutate the item's `dc.rights*` metadata. + */ + @Test + public void patchSelectWithUnknownLicenseNameFails() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + List rightsBefore = itemService.getMetadata(witem.getItem(), "dc", "rights", null, + null, Item.ANY); + List rightsBeforeValues = new ArrayList(); + for (MetadataValue metadataValue : rightsBefore) { + rightsBeforeValues.add(metadataValue.getValue()); + } + context.restoreAuthSystemState(); + + List ops = new ArrayList(); + Map v = new HashMap(); + v.put("value", "Definitely Not An Existing License"); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v)); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isUnprocessableEntity()); + + witem = context.reloadEntity(witem); + List rightsAfter = itemService.getMetadata(witem.getItem(), "dc", "rights", null, + null, Item.ANY); + List rightsAfterValues = new ArrayList(); + for (MetadataValue metadataValue : rightsAfter) { + rightsAfterValues.add(metadataValue.getValue()); + } + Assert.assertEquals(rightsBeforeValues, rightsAfterValues); + } + + /** + * Anonymous PATCH on `/sections/clarin-license/select` must be rejected + * (the user is not authenticated to modify the submission). + */ + @Test + public void patchSelectAsAnonymousIsUnauthorized() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + String clarinLicenseName = "Anon Clarin License"; + createClarinLicense(clarinLicenseName, "Def", "Info", Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + List ops = new ArrayList(); + Map v = new HashMap(); + v.put("value", clarinLicenseName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v)); + + getClient().perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isUnauthorized()); + } + /** * Create Item with standard handle. The handle definition for every community is configured * by the `lr.pid.community.configurations` properties.