diff --git a/doc/release-notes/11771-update-dataset-license-api.md b/doc/release-notes/11771-update-dataset-license-api.md new file mode 100644 index 00000000000..e87f7e0f93f --- /dev/null +++ b/doc/release-notes/11771-update-dataset-license-api.md @@ -0,0 +1,13 @@ +## New Endpoint: `/datasets/{id}/license` + +A new endpoint has been implemented to manage dataset licenses. + +### Functionality +- Updates the license of a dataset by applying it to the draft version. +- If no draft exists, a new one is automatically created. + +### Usage +This endpoint supports two ways of defining a license: +1. **Predefined License** – Provide the license name (e.g., `CC BY 4.0`). +2. **Custom Terms of Use and Access** – Provide a JSON body with the `customTerms` object. + - All fields are optional **except** `termsOfUse`, which is required. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index b2eee36aeb4..ddfc73b3c4f 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -4357,6 +4357,53 @@ The CSV response for this call is the same as for the /api/datasets/{id}/assignm Note: This feature requires the "role-assignment-history" feature flag to be enabled (see :ref:`feature-flags`). +Update Dataset License +~~~~~~~~~~~~~~~~~~~~~~ + +Updates the license of a dataset by applying it to the draft version, or by creating a draft if none exists. + +The JSON representation of a license can take two forms, depending on whether you want to specify a predefined license or define custom terms of use and access. + +To set a predefined license (e.g., CC BY 4.0), provide a JSON body with the license name: + +.. code-block:: json + + { + "name": "CC BY 4.0" + } + +To define custom terms of use and access, provide a JSON body with the following properties. All fields within ``customTerms`` are optional, except for the ``termsOfUse`` field, which is required: + +.. code-block:: json + + { + "customTerms": { + "termsOfUse": "Your terms of use", + "confidentialityDeclaration": "Your confidentiality declaration", + "specialPermissions": "Your special permissions", + "restrictions": "Your restrictions", + "citationRequirements": "Your citation requirements", + "depositorRequirements": "Your depositor requirements", + "conditions": "Your conditions", + "disclaimer": "Your disclaimer" + } + } + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export ID=3 + export FILE_PATH=license.json + + curl -H "X-Dataverse-key:$API_TOKEN" -X PUT "$SERVER_URL/api/datasets/$ID/license" -H "Content-type:application/json" --upload-file $FILE_PATH + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/3/license" -H "Content-type:application/json" --upload-file license.json + Files ----- diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 14b0ab6d2c0..4cb3466ab4c 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -24,6 +24,7 @@ import edu.harvard.iq.dataverse.engine.command.impl.GetSpecificPublishedDatasetVersionCommand; import edu.harvard.iq.dataverse.externaltools.ExternalToolServiceBean; import edu.harvard.iq.dataverse.license.LicenseServiceBean; +import edu.harvard.iq.dataverse.makedatacount.DatasetMetricsServiceBean; import edu.harvard.iq.dataverse.pidproviders.FailedPIDResolutionLoggingServiceBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; import edu.harvard.iq.dataverse.pidproviders.FailedPIDResolutionLoggingServiceBean.FailedPIDResolutionEntry; @@ -224,6 +225,9 @@ String getWrappedMessageWhenJson() { @EJB protected ExternalToolServiceBean externalToolService; + @EJB + protected DatasetMetricsServiceBean datasetMetricsService; + @EJB DataFileServiceBean fileSvc; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 12715d31e77..51f4ec607ef 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -2,11 +2,10 @@ import edu.harvard.iq.dataverse.*; import edu.harvard.iq.dataverse.DatasetLock.Reason; -import edu.harvard.iq.dataverse.DatasetVersion.VersionState; -import edu.harvard.iq.dataverse.DataverseRoleServiceBean.RoleAssignmentHistoryConsolidatedEntry; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; -import edu.harvard.iq.dataverse.api.AbstractApiBean.WrappedResponse; import edu.harvard.iq.dataverse.api.auth.AuthRequired; +import edu.harvard.iq.dataverse.api.dto.CustomTermsDTO; +import edu.harvard.iq.dataverse.api.dto.LicenseUpdateRequest; import edu.harvard.iq.dataverse.api.dto.RoleAssignmentDTO; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.DataverseRole; @@ -31,7 +30,6 @@ import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.exception.*; import edu.harvard.iq.dataverse.engine.command.impl.*; -import edu.harvard.iq.dataverse.export.DDIExportServiceBean; import edu.harvard.iq.dataverse.export.ExportService; import edu.harvard.iq.dataverse.externaltools.ExternalTool; import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; @@ -142,9 +140,6 @@ public class Datasets extends AbstractApiBean { @EJB AuthenticationServiceBean authenticationServiceBean; - @EJB - DDIExportServiceBean ddiExportService; - @EJB MetadataBlockServiceBean metadataBlockService; @@ -166,10 +161,6 @@ public class Datasets extends AbstractApiBean { @EJB SettingsServiceBean settingsService; - // TODO: Move to AbstractApiBean - @EJB - DatasetMetricsServiceBean datasetMetricsSvc; - @EJB DatasetExternalCitationsServiceBean datasetExternalCitationsService; @@ -203,9 +194,6 @@ public class Datasets extends AbstractApiBean { @Inject DatasetTypeServiceBean datasetTypeSvc; - @Inject - DatasetFieldsValidator datasetFieldsValidator; - @Inject DataFileCategoryServiceBean dataFileCategoryService; @@ -1155,16 +1143,16 @@ public Response editVersionMetadata(@Context ContainerRequestContext crc, String return ex.getResponse(); } } - + @PUT @AuthRequired @Path("{id}/access") public Response editVersionTermsOfAccess(@Context ContainerRequestContext crc, String jsonBody, @PathParam("id") String id, @QueryParam("sourceLastUpdateTime") String sourceLastUpdateTime) { try { - + boolean publicInstall = settingsSvc.isTrueForKey(SettingsServiceBean.Key.PublicInstall, false); - + Dataset dataset = findDatasetOrDie(id); if (sourceLastUpdateTime != null) { @@ -1174,11 +1162,11 @@ public Response editVersionTermsOfAccess(@Context ContainerRequestContext crc, S JsonObject json = JsonUtil.getJsonObject(jsonBody); TermsOfUseAndAccess toua = jsonParser().parseTermsOfAccess(json); - + if (publicInstall && (toua.isFileAccessRequest() || !toua.getTermsOfAccess().isEmpty())){ return error(BAD_REQUEST, "Setting File Access Request or Terms of Access is not permitted on a public installation."); } - + DatasetVersion updatedVersion = execCommand(new UpdateDatasetTermsOfAccessCommand(dataset, toua, createDataverseRequest(getRequestUser(crc)))).getLatestVersion(); return ok(json(updatedVersion, true)); @@ -3575,7 +3563,7 @@ public Response getMakeDataCountMetric(@PathParam("id") String idSupplied, @Path return error(Response.Status.BAD_REQUEST, "Country must be one of the ISO 1366 Country Codes"); } } - DatasetMetrics datasetMetrics = datasetMetricsSvc.getDatasetMetricsByDatasetForDisplay(dataset, monthYear, country); + DatasetMetrics datasetMetrics = datasetMetricsService.getDatasetMetricsByDatasetForDisplay(dataset, monthYear, country); if (datasetMetrics == null) { return ok("No metrics available for dataset " + dataset.getId() + " for " + yyyymm + " for country code " + country + "."); } else if (datasetMetrics.getDownloadsTotal() + datasetMetrics.getViewsTotal() == 0) { @@ -6041,7 +6029,7 @@ public Response deleteDatasetFiles(@Context ContainerRequestContext crc, @PathPa }, getRequestUser(crc)); } -@GET + @GET @AuthRequired @Path("{id}/versions/{versionId}/versionNote") public Response getVersionCreationNote(@Context ContainerRequestContext crc, @PathParam("id") String datasetId, @PathParam("versionId") String versionId, @Context UriInfo uriInfo, @Context HttpHeaders headers) throws WrappedResponse { @@ -6110,7 +6098,7 @@ public Response deleteVersionNote(@Context ContainerRequestContext crc, @PathPar return ok("Note deleted"); }, getRequestUser(crc)); } - + @GET @AuthRequired @Path("{identifier}/assignments/history") @@ -6118,7 +6106,7 @@ public Response deleteVersionNote(@Context ContainerRequestContext crc, @PathPar public Response getRoleAssignmentHistory(@Context ContainerRequestContext crc, @PathParam("identifier") String id, @Context HttpHeaders headers) { return response(req -> { Dataset dataset = findDatasetOrDie(id); - + // user is authenticated AuthenticatedUser authenticatedUser = getRequestAuthenticatedUserOrDie(crc); @@ -6135,11 +6123,37 @@ public Response getFilesRoleAssignmentHistory(@Context ContainerRequestContext c @Context HttpHeaders headers) { return response(req -> { Dataset dataset = findDatasetOrDie(id); - + // user is authenticated AuthenticatedUser authenticatedUser = getRequestAuthenticatedUserOrDie(crc); return getRoleAssignmentHistoryResponse(dataset, authenticatedUser, true, headers); }, getRequestUser(crc)); } + + @PUT + @AuthRequired + @Path("{id}/license") + public Response updateLicense(@Context ContainerRequestContext crc, + @PathParam("id") String datasetId, + LicenseUpdateRequest requestBody) { + return response(req -> { + Dataset dataset = findDatasetOrDie(datasetId); + if (requestBody.getName() != null && !requestBody.getName().isEmpty()) { + String licenseName = requestBody.getName(); + License license = licenseSvc.getByNameOrUri(licenseName); + if (license == null) { + return notFound(BundleUtil.getStringFromBundle("datasets.api.updateLicense.licenseNotFound", List.of(licenseName))); + } + execCommand(new UpdateDatasetLicenseCommand(req, dataset, license)); + return ok(BundleUtil.getStringFromBundle("datasets.api.updateLicense.success")); + } else if (requestBody.getCustomTerms() != null) { + CustomTermsDTO customTerms = requestBody.getCustomTerms(); + execCommand(new UpdateDatasetLicenseCommand(req, dataset, customTerms.toTermsOfUseAndAccess())); + return ok(BundleUtil.getStringFromBundle("datasets.api.updateLicense.success")); + } else { + return badRequest(BundleUtil.getStringFromBundle("datasets.api.updateLicense.licenseNameIsEmpty")); + } + }, getRequestUser(crc)); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/MakeDataCountApi.java b/src/main/java/edu/harvard/iq/dataverse/api/MakeDataCountApi.java index ca4f55da822..07e87a9b86a 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/MakeDataCountApi.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/MakeDataCountApi.java @@ -1,28 +1,21 @@ package edu.harvard.iq.dataverse.api; import edu.harvard.iq.dataverse.Dataset; -import edu.harvard.iq.dataverse.DatasetServiceBean; import edu.harvard.iq.dataverse.GlobalId; import edu.harvard.iq.dataverse.makedatacount.DatasetExternalCitations; import edu.harvard.iq.dataverse.makedatacount.DatasetExternalCitationsServiceBean; import edu.harvard.iq.dataverse.makedatacount.DatasetMetrics; -import edu.harvard.iq.dataverse.makedatacount.DatasetMetricsServiceBean; import edu.harvard.iq.dataverse.makedatacount.MakeDataCountProcessState; import edu.harvard.iq.dataverse.makedatacount.MakeDataCountProcessStateServiceBean; import edu.harvard.iq.dataverse.pidproviders.PidProvider; import edu.harvard.iq.dataverse.pidproviders.PidUtil; import edu.harvard.iq.dataverse.pidproviders.doi.datacite.DataCiteDOIProvider; import edu.harvard.iq.dataverse.settings.JvmSettings; -import edu.harvard.iq.dataverse.util.SystemConfig; import edu.harvard.iq.dataverse.util.json.JsonUtil; import java.io.IOException; import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; +import java.net.*; import java.util.Iterator; import java.util.List; import java.util.concurrent.Future; @@ -57,16 +50,10 @@ public class MakeDataCountApi extends AbstractApiBean { private static final Logger logger = Logger.getLogger(MakeDataCountApi.class.getCanonicalName()); - @EJB - DatasetMetricsServiceBean datasetMetricsService; @EJB MakeDataCountProcessStateServiceBean makeDataCountProcessStateService; @EJB DatasetExternalCitationsServiceBean datasetExternalCitationsService; - @EJB - DatasetServiceBean datasetService; - @EJB - SystemConfig systemConfig; // Inject the managed executor service provided by the container @Resource(name = "concurrent/CitationUpdateExecutor") diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/CustomTermsDTO.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/CustomTermsDTO.java new file mode 100644 index 00000000000..b2d00e01ef5 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/CustomTermsDTO.java @@ -0,0 +1,91 @@ +package edu.harvard.iq.dataverse.api.dto; + +import edu.harvard.iq.dataverse.TermsOfUseAndAccess; + +public class CustomTermsDTO { + private String termsOfUse; + private String confidentialityDeclaration; + private String specialPermissions; + private String restrictions; + private String citationRequirements; + private String depositorRequirements; + private String conditions; + private String disclaimer; + + public String getTermsOfUse() { + return termsOfUse; + } + + public void setTermsOfUse(String termsOfUse) { + this.termsOfUse = termsOfUse; + } + + public String getConfidentialityDeclaration() { + return confidentialityDeclaration; + } + + public void setConfidentialityDeclaration(String confidentialityDeclaration) { + this.confidentialityDeclaration = confidentialityDeclaration; + } + + public String getSpecialPermissions() { + return specialPermissions; + } + + public void setSpecialPermissions(String specialPermissions) { + this.specialPermissions = specialPermissions; + } + + public String getRestrictions() { + return restrictions; + } + + public void setRestrictions(String restrictions) { + this.restrictions = restrictions; + } + + public String getCitationRequirements() { + return citationRequirements; + } + + public void setCitationRequirements(String citationRequirements) { + this.citationRequirements = citationRequirements; + } + + public String getDepositorRequirements() { + return depositorRequirements; + } + + public void setDepositorRequirements(String depositorRequirements) { + this.depositorRequirements = depositorRequirements; + } + + public String getConditions() { + return conditions; + } + + public void setConditions(String conditions) { + this.conditions = conditions; + } + + public String getDisclaimer() { + return disclaimer; + } + + public void setDisclaimer(String disclaimer) { + this.disclaimer = disclaimer; + } + + public TermsOfUseAndAccess toTermsOfUseAndAccess() { + TermsOfUseAndAccess termsOfUseAndAccess = new TermsOfUseAndAccess(); + termsOfUseAndAccess.setTermsOfUse(termsOfUse); + termsOfUseAndAccess.setConfidentialityDeclaration(confidentialityDeclaration); + termsOfUseAndAccess.setSpecialPermissions(specialPermissions); + termsOfUseAndAccess.setRestrictions(restrictions); + termsOfUseAndAccess.setCitationRequirements(citationRequirements); + termsOfUseAndAccess.setDepositorRequirements(depositorRequirements); + termsOfUseAndAccess.setConditions(conditions); + termsOfUseAndAccess.setDisclaimer(disclaimer); + return termsOfUseAndAccess; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/dto/LicenseUpdateRequest.java b/src/main/java/edu/harvard/iq/dataverse/api/dto/LicenseUpdateRequest.java new file mode 100644 index 00000000000..bba9dab7a25 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/api/dto/LicenseUpdateRequest.java @@ -0,0 +1,28 @@ +package edu.harvard.iq.dataverse.api.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +// This DTO acts as a wrapper for the request body. +// It can accept EITHER a 'name' or a 'customTerms' object. +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LicenseUpdateRequest { + + private String name; + private CustomTermsDTO customTerms; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public CustomTermsDTO getCustomTerms() { + return customTerms; + } + + public void setCustomTerms(CustomTermsDTO customTerms) { + this.customTerms = customTerms; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommand.java new file mode 100644 index 00000000000..37f97bf0db1 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommand.java @@ -0,0 +1,76 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.TermsOfUseAndAccess; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.*; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidCommandArgumentsException; +import edu.harvard.iq.dataverse.license.License; +import edu.harvard.iq.dataverse.util.BundleUtil; + +import java.util.List; + +@RequiredPermissions(Permission.EditDataset) +public class UpdateDatasetLicenseCommand extends AbstractVoidCommand { + private final Dataset dataset; + private License license = null; + private TermsOfUseAndAccess customTermsOfUseAndAccess = null; + + public UpdateDatasetLicenseCommand(DataverseRequest aRequest, Dataset dataset, License license) { + super(aRequest, dataset); + this.dataset = dataset; + this.license = license; + } + + public UpdateDatasetLicenseCommand(DataverseRequest aRequest, Dataset dataset, TermsOfUseAndAccess customTermsOfUseAndAccess) { + super(aRequest, dataset); + this.dataset = dataset; + this.customTermsOfUseAndAccess = customTermsOfUseAndAccess; + } + + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + DatasetVersion datasetVersion = dataset.getOrCreateEditVersion(); + datasetVersion.setVersionState(DatasetVersion.VersionState.DRAFT); + + if (license != null) { + if (!license.isActive()) { + throw new InvalidCommandArgumentsException(BundleUtil.getStringFromBundle("updateDatasetLicenseCommand.errors.licenseNotActive", List.of(license.getName())), this); + } + TermsOfUseAndAccess termsOfUseAndAccess = datasetVersion.getTermsOfUseAndAccess(); + termsOfUseAndAccess.setLicense(license); + + ctxt.engine().submit(new UpdateDatasetVersionCommand(this.dataset, getRequest())); + } else if (customTermsOfUseAndAccess != null) { + if (customTermsOfUseAndAccess.getTermsOfUse() == null || customTermsOfUseAndAccess.getTermsOfUse().isBlank()) { + throw new InvalidCommandArgumentsException(BundleUtil.getStringFromBundle("updateDatasetLicenseCommand.errors.customTermsOfUseNotProvided"), this); + } + TermsOfUseAndAccess termsToUpdate = datasetVersion.getTermsOfUseAndAccess(); + applyCustomTerms(termsToUpdate, customTermsOfUseAndAccess); + termsToUpdate.setLicense(null); + datasetVersion.setTermsOfUseAndAccess(termsToUpdate); + ctxt.engine().submit(new UpdateDatasetVersionCommand(this.dataset, getRequest())); + } + } + + /** + * Copies all custom term-related fields from the 'source' object + * to the 'target' object. + * + * @param target The TermsOfUseAndAccess object to be modified + * @param source The TermsOfUseAndAccess object containing the new data + */ + private void applyCustomTerms(TermsOfUseAndAccess target, TermsOfUseAndAccess source) { + target.setTermsOfUse(source.getTermsOfUse()); + target.setConfidentialityDeclaration(source.getConfidentialityDeclaration()); + target.setSpecialPermissions(source.getSpecialPermissions()); + target.setRestrictions(source.getRestrictions()); + target.setCitationRequirements(source.getCitationRequirements()); + target.setDepositorRequirements(source.getDepositorRequirements()); + target.setConditions(source.getConditions()); + target.setDisclaimer(source.getDisclaimer()); + } +} diff --git a/src/main/java/propertyFiles/Bundle.properties b/src/main/java/propertyFiles/Bundle.properties index f6ca8a9d4f0..3254c26ed22 100644 --- a/src/main/java/propertyFiles/Bundle.properties +++ b/src/main/java/propertyFiles/Bundle.properties @@ -2866,6 +2866,9 @@ datasets.api.thumbnail.fileNotSupplied=A file was not selected to be the new dat datasets.api.thumbnail.noChange=No changes to save. datasets.api.editMetadata.error.parseUpdate=Error parsing dataset update: {0} datasets.api.citation.invalidFormat=Invalid Format Requested. +datasets.api.updateLicense.success=Dataset license updated. +datasets.api.updateLicense.licenseNotFound=License with name {0} not found. +datasets.api.updateLicense.licenseNameIsEmpty=License name cannot be empty. #Dataverses.java dataverses.api.update.default.contributor.role.failure.role.not.found=Role {0} not found. @@ -3249,3 +3252,7 @@ getUserPermittedCollectionsCommand.errors.permissionNotValid=Permission not vali #AbstractPaginatedCommand.java abstractPaginatedCommand.errors.negativePaginationParam=The {0} parameter cannot be negative. + +#UpdateDatasetLicenseCommand.java +updateDatasetLicenseCommand.errors.licenseNotActive=License {0} cannot be set because it is not active. +updateDatasetLicenseCommand.errors.customTermsOfUseNotProvided=Terms of use text should be provided in custom terms. diff --git a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java index 3a34c3607cd..97e17c67a5d 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/DatasetsIT.java @@ -7227,6 +7227,128 @@ public void testUpdateMultipleFileMetadata() { .statusCode(OK.getStatusCode()); } + @Test + public void testUpdateLicense() { + Response createUser = UtilIT.createRandomUser(); + String apiToken = UtilIT.getApiTokenFromResponse(createUser); + + // Test setup: Create a user and a published dataverse to host the dataset. + Response createDataverseResponse = UtilIT.createRandomDataverse(apiToken); + String ownerAlias = UtilIT.getAliasFromResponse(createDataverseResponse); + UtilIT.publishDataverseViaNativeApi(ownerAlias, apiToken); + + // Test setup: Create and publish a dataset within the new dataverse. + Response createDatasetResponse = UtilIT.createRandomDatasetViaNativeApi(ownerAlias, apiToken); + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDatasetResponse); + String datasetPersistentId = UtilIT.getDatasetPersistentIdFromResponse(createDatasetResponse); + UtilIT.publishDatasetViaNativeApi(datasetId, "major", apiToken); + + // Verify the dataset's initial state, ensuring it has the default CC0 1.0 license. + Response getDatasetVersion = UtilIT.getDatasetVersion(datasetPersistentId, DS_VERSION_LATEST, apiToken); + getDatasetVersion.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.license.name", equalTo("CC0 1.0")); + + // Test case 1: Update to a valid, predefined license (CC BY 4.0). + Response updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), "{ \"name\": \"CC BY 4.0\" }", apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo(BundleUtil.getStringFromBundle("datasets.api.updateLicense.success"))); + + getDatasetVersion = UtilIT.getDatasetVersion(datasetPersistentId, DS_VERSION_LATEST, apiToken); + getDatasetVersion.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.license.name", equalTo("CC BY 4.0")); + + // Test case 2: Attempt to update with an invalid license name and verify the expected error. + String testInvalidLicenseName = "INVALID LICENSE 4.0"; + updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), "{ \"name\": \"" + testInvalidLicenseName + "\" }", apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(NOT_FOUND.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("datasets.api.updateLicense.licenseNotFound", List.of(testInvalidLicenseName)))); + + // Test case 3: Update with custom terms, providing only the required 'termsOfUse' field. + String jsonString = """ + { + "customTerms": { + "termsOfUse": "testTermsOfUse" + } + } + """; + + updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), jsonString, apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo(BundleUtil.getStringFromBundle("datasets.api.updateLicense.success"))); + + getDatasetVersion = UtilIT.getDatasetVersion(datasetPersistentId, DS_VERSION_LATEST, apiToken); + getDatasetVersion.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.license", equalTo(null)) + .body("data.termsOfUse", equalTo("testTermsOfUse")) + .body("data.confidentialityDeclaration", equalTo(null)) + .body("data.specialPermissions", equalTo(null)) + .body("data.restrictions", equalTo(null)) + .body("data.citationRequirements", equalTo(null)) + .body("data.depositorRequirements", equalTo(null)) + .body("data.conditions", equalTo(null)) + .body("data.disclaimer", equalTo(null)); + + // Test case 4: Update with a complete set of custom terms, including all optional fields. + jsonString = """ + { + "customTerms": { + "termsOfUse": "testTermsOfUse", + "confidentialityDeclaration": "testConfidentialityDeclaration", + "specialPermissions": "testSpecialPermissions", + "restrictions": "testRestrictions", + "citationRequirements": "testCitationRequirements", + "depositorRequirements": "testDepositorRequirements", + "conditions": "testConditions", + "disclaimer": "testDisclaimer" + } + } + """; + + updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), jsonString, apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.message", equalTo(BundleUtil.getStringFromBundle("datasets.api.updateLicense.success"))); + + getDatasetVersion = UtilIT.getDatasetVersion(datasetPersistentId, DS_VERSION_LATEST, apiToken); + getDatasetVersion.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.license", equalTo(null)) + .body("data.termsOfUse", equalTo("testTermsOfUse")) + .body("data.confidentialityDeclaration", equalTo("testConfidentialityDeclaration")) + .body("data.specialPermissions", equalTo("testSpecialPermissions")) + .body("data.restrictions", equalTo("testRestrictions")) + .body("data.citationRequirements", equalTo("testCitationRequirements")) + .body("data.depositorRequirements", equalTo("testDepositorRequirements")) + .body("data.conditions", equalTo("testConditions")) + .body("data.disclaimer", equalTo("testDisclaimer")); + + // Test case 5: Ensure that providing an empty 'customTerms' object results in a validation error. + jsonString = """ + { + "customTerms": {} + } + """; + + updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), jsonString, apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(BAD_REQUEST.getStatusCode()) + .body("message", equalTo(BundleUtil.getStringFromBundle("updateDatasetLicenseCommand.errors.customTermsOfUseNotProvided"))); + + // Test case 6: The operation should return a permissions error when invoked by a user with insufficient permissions. + createUser = UtilIT.createRandomUser(); + apiToken = UtilIT.getApiTokenFromResponse(createUser); + + updateLicenseResponse = UtilIT.updateLicense(datasetId.toString(), "{ \"name\": \"CC BY 4.0\" }", apiToken); + updateLicenseResponse.then().assertThat() + .statusCode(UNAUTHORIZED.getStatusCode()); + } + private String getSuperuserToken() { Response createResponse = UtilIT.createRandomUser(); String adminApiToken = UtilIT.getApiTokenFromResponse(createResponse); diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 9724efb2d32..5a07769b313 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -1803,7 +1803,7 @@ static Response compareDatasetVersions(String persistentId, String versionNumber static Response summaryDatasetVersionDifferences(String persistentId, String apiToken) { return summaryDatasetVersionDifferences(persistentId, null, null, apiToken); } - + static Response summaryDatasetVersionDifferences(String persistentId, Integer limit, Integer offset, String apiToken) { return given() .header(API_TOKEN_HTTP_HEADER, apiToken) @@ -3882,7 +3882,7 @@ static Response updateDatasetJsonLDMetadata(Integer datasetId, String apiToken, .put("/api/datasets/" + datasetId + "/metadata?replace=" + replace); return response; } - + static Response updateDatasetTermsAndAccess(String idOrPersistentIdOfDataset, String apiToken, String pathToJsonFile) { String idInPath = idOrPersistentIdOfDataset; String optionalQueryParam = ""; @@ -3899,7 +3899,7 @@ static Response updateDatasetTermsAndAccess(String idOrPersistentIdOfDataset, St .put("/api/datasets/" + idInPath + "/access" + optionalQueryParam); return response; } - + public static String getDatasetTermsFromFile(String pathToJsonFile) { File datasetTermsJson = new File(pathToJsonFile); try { @@ -5112,35 +5112,43 @@ public static Response callCallbackUrl(String callbackUrl) { .when() .get(callbackUrl); } - + public static Response getDataverseRoleAssignmentHistory(String dataverseAlias, boolean downloadAsCsv, String apiToken) { RequestSpecification requestSpecification = given() .header(API_TOKEN_HTTP_HEADER, apiToken); - + requestSpecification = requestSpecification.header("Accept", downloadAsCsv ? "text/csv" : "application/json"); - + return requestSpecification .get("/api/v1/dataverses/" + dataverseAlias + "/assignments/history"); } - + public static Response getDatasetRoleAssignmentHistory(Integer datasetId, boolean downloadAsCsv, String apiToken) { RequestSpecification requestSpecification = given() .header(API_TOKEN_HTTP_HEADER, apiToken); - + requestSpecification = requestSpecification.header("Accept", downloadAsCsv ? "text/csv" : "application/json"); - + return requestSpecification .get("/api/v1/datasets/" + datasetId + "/assignments/history"); } - + public static Response getDatasetFilesRoleAssignmentHistory(Integer datasetId, boolean downloadAsCsv, String apiToken) { RequestSpecification requestSpecification = given() .header(API_TOKEN_HTTP_HEADER, apiToken); - + requestSpecification = requestSpecification.header("Accept", downloadAsCsv ? "text/csv" : "application/json"); - + return requestSpecification .get("/api/v1/datasets/" + datasetId + "/files/assignments/history"); } + + public static Response updateLicense(String datasetId, String licenseOrCustomTerms, String apiToken) { + return given() + .body(licenseOrCustomTerms) + .contentType(ContentType.JSON) + .header(API_TOKEN_HTTP_HEADER, apiToken) + .put("/api/datasets/" + datasetId + "/license"); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommandTest.java b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommandTest.java new file mode 100644 index 00000000000..78d5e99546c --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDatasetLicenseCommandTest.java @@ -0,0 +1,151 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.DatasetVersion; +import edu.harvard.iq.dataverse.TermsOfUseAndAccess; +import edu.harvard.iq.dataverse.engine.DataverseEngine; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.InvalidCommandArgumentsException; +import edu.harvard.iq.dataverse.license.License; +import edu.harvard.iq.dataverse.util.BundleUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class UpdateDatasetLicenseCommandTest { + + @Mock + private DataverseRequest dataverseRequestStub; + @Mock + private UpdateDatasetVersionCommand updateDatasetVersionCommandStub; + @Mock + private Dataset datasetMock; + @Mock + private DatasetVersion datasetVersionMock; + @Spy + private TermsOfUseAndAccess termsOfUseAndAccessSpy = new TermsOfUseAndAccess(); + @Mock + private TermsOfUseAndAccess customTermsOfUseAndAccessMock; + @Mock + private DataverseEngine dataverseEngineMock; + @Mock + private CommandContext commandContextMock; + + private License activeLicense; + private License inactiveLicense; + + @BeforeEach + public void setUp() throws CommandException { + MockitoAnnotations.openMocks(this); + + when(datasetMock.getOrCreateEditVersion()).thenReturn(datasetVersionMock); + when(datasetVersionMock.getTermsOfUseAndAccess()).thenReturn(termsOfUseAndAccessSpy); + when(dataverseEngineMock.submit(updateDatasetVersionCommandStub)).thenReturn(datasetMock); + when(commandContextMock.engine()).thenReturn(dataverseEngineMock); + + activeLicense = new License(); + activeLicense.setActive(true); + activeLicense.setName("activeLicense"); + + inactiveLicense = new License(); + inactiveLicense.setActive(false); + inactiveLicense.setName("inactiveLicense"); + } + + @Test + public void execute_shouldUpdateLicenseAndSetVersionStateToDraft() throws CommandException { + // Arrange + UpdateDatasetLicenseCommand sut = new UpdateDatasetLicenseCommand(dataverseRequestStub, datasetMock, activeLicense); + + // Act + sut.execute(commandContextMock); + + // Assert + assertEquals(activeLicense, termsOfUseAndAccessSpy.getLicense()); + verify(datasetVersionMock).setVersionState(DatasetVersion.VersionState.DRAFT); + verify(commandContextMock).engine(); + } + + @Test + public void execute_shouldThrowException_whenLicenseIsNotActive() { + // Arrange + UpdateDatasetLicenseCommand sut = new UpdateDatasetLicenseCommand(dataverseRequestStub, datasetMock, inactiveLicense); + String expectedMessage = BundleUtil.getStringFromBundle("updateDatasetLicenseCommand.errors.licenseNotActive", List.of(inactiveLicense.getName())); + + // Act & Assert + InvalidCommandArgumentsException exception = assertThrows(InvalidCommandArgumentsException.class, () -> sut.execute(commandContextMock)); + assertEquals(expectedMessage, exception.getMessage()); + } + + @Test + public void execute_shouldUpdateCustomTermsAndSetVersionStateToDraft() throws CommandException { + // Arrange + String termsOfUse = "custom terms"; + String confidentialityDeclaration = "confidentiality"; + String specialPermissions = "special permissions"; + String restrictions = "restrictions"; + String citationRequirements = "citation"; + String depositorRequirements = "depositor"; + String conditions = "conditions"; + String disclaimer = "disclaimer"; + + when(customTermsOfUseAndAccessMock.getTermsOfUse()).thenReturn(termsOfUse); + when(customTermsOfUseAndAccessMock.getConfidentialityDeclaration()).thenReturn(confidentialityDeclaration); + when(customTermsOfUseAndAccessMock.getSpecialPermissions()).thenReturn(specialPermissions); + when(customTermsOfUseAndAccessMock.getRestrictions()).thenReturn(restrictions); + when(customTermsOfUseAndAccessMock.getCitationRequirements()).thenReturn(citationRequirements); + when(customTermsOfUseAndAccessMock.getDepositorRequirements()).thenReturn(depositorRequirements); + when(customTermsOfUseAndAccessMock.getConditions()).thenReturn(conditions); + when(customTermsOfUseAndAccessMock.getDisclaimer()).thenReturn(disclaimer); + + termsOfUseAndAccessSpy.setLicense(activeLicense); + UpdateDatasetLicenseCommand sut = new UpdateDatasetLicenseCommand(dataverseRequestStub, datasetMock, customTermsOfUseAndAccessMock); + + // Act + sut.execute(commandContextMock); + + // Assert + verify(datasetVersionMock).setVersionState(DatasetVersion.VersionState.DRAFT); + + assertEquals(termsOfUse, termsOfUseAndAccessSpy.getTermsOfUse()); + assertEquals(confidentialityDeclaration, termsOfUseAndAccessSpy.getConfidentialityDeclaration()); + assertEquals(specialPermissions, termsOfUseAndAccessSpy.getSpecialPermissions()); + assertEquals(restrictions, termsOfUseAndAccessSpy.getRestrictions()); + assertEquals(citationRequirements, termsOfUseAndAccessSpy.getCitationRequirements()); + assertEquals(depositorRequirements, termsOfUseAndAccessSpy.getDepositorRequirements()); + assertEquals(conditions, termsOfUseAndAccessSpy.getConditions()); + assertEquals(disclaimer, termsOfUseAndAccessSpy.getDisclaimer()); + assertEquals(null, termsOfUseAndAccessSpy.getLicense()); + + verify(datasetVersionMock).setTermsOfUseAndAccess(termsOfUseAndAccessSpy); + verify(commandContextMock).engine(); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + public void execute_shouldThrowException_whenCustomTermsOfUseAreNullOrBlank(String invalidTerms) { + // Arrange + when(customTermsOfUseAndAccessMock.getTermsOfUse()).thenReturn(invalidTerms); + UpdateDatasetLicenseCommand sut = new UpdateDatasetLicenseCommand(dataverseRequestStub, datasetMock, customTermsOfUseAndAccessMock); + String expectedMessage = BundleUtil.getStringFromBundle("updateDatasetLicenseCommand.errors.customTermsOfUseNotProvided"); + + // Act & Assert + InvalidCommandArgumentsException exception = assertThrows(InvalidCommandArgumentsException.class, () -> sut.execute(commandContextMock)); + assertEquals(expectedMessage, exception.getMessage()); + } +}