diff --git a/core/src/main/java/org/fao/geonet/kernel/metadata/MetadataPublicationService.java b/core/src/main/java/org/fao/geonet/kernel/metadata/MetadataPublicationService.java index 201fb84e33e..13010e8ca10 100644 --- a/core/src/main/java/org/fao/geonet/kernel/metadata/MetadataPublicationService.java +++ b/core/src/main/java/org/fao/geonet/kernel/metadata/MetadataPublicationService.java @@ -833,19 +833,22 @@ public List reservedGroupsPrivilegesStatusChanges(Sharing } /** - * For privileges to {@link ReservedGroup#all} group, check if it's allowed or not to publish invalid metadata. + * For privileges to {@link ReservedGroup#all} group, check if it's allowed or not to publish invalid or + * non-approved metadata. Templates are excluded from the validation check as they are incomplete by design. * - * @param context - * @param messages - * @param metadata - * @param allowPublishInvalidMd - * @param allowPublishNonApprovedMd - * @throws Exception + * @param context the current service context + * @param messages the resource bundle for error messages + * @param metadata the metadata record being published + * @param allowPublishInvalidMd whether publishing invalid metadata is allowed (excluding templates) + * @param allowPublishNonApprovedMd whether publishing non-approved metadata is allowed + * @throws Exception if the metadata is invalid or not approved and publishing is not allowed */ private void checkCanPublishToAllGroup(ServiceContext context, ResourceBundle messages, AbstractMetadata metadata, boolean allowPublishInvalidMd, boolean allowPublishNonApprovedMd) throws Exception { - if (!allowPublishInvalidMd) { + boolean metadataTypeRequiresValidation = metadata.getDataInfo().getType().requiresValidation; + + if (!allowPublishInvalidMd && metadataTypeRequiresValidation) { boolean hasValidation = (metadataValidationRepository.count(MetadataValidationSpecs.hasMetadataId(metadata.getId())) > 0); diff --git a/core/src/test/java/org/fao/geonet/kernel/metadata/MetadataPublicationServiceTest.java b/core/src/test/java/org/fao/geonet/kernel/metadata/MetadataPublicationServiceTest.java index c2cc707c492..be1a930a63e 100644 --- a/core/src/test/java/org/fao/geonet/kernel/metadata/MetadataPublicationServiceTest.java +++ b/core/src/test/java/org/fao/geonet/kernel/metadata/MetadataPublicationServiceTest.java @@ -25,18 +25,22 @@ import jeeves.server.UserSession; import jeeves.server.context.ServiceContext; import org.fao.geonet.api.exception.NotAllowedException; -import org.fao.geonet.domain.AbstractMetadata; -import org.fao.geonet.domain.MetadataSourceInfo; -import org.fao.geonet.domain.Profile; +import org.fao.geonet.domain.*; import org.fao.geonet.kernel.AccessManager; import org.fao.geonet.kernel.setting.SettingManager; import org.fao.geonet.kernel.setting.Settings; +import org.fao.geonet.repository.MetadataValidationRepository; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.data.jpa.domain.Specification; + +import java.lang.reflect.Method; +import java.util.ResourceBundle; + import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -61,6 +65,10 @@ public class MetadataPublicationServiceTest { private static final String METADATA_UUID = "uuid-123"; private static final int GROUP_OWNER = 12; + @Mock + private MetadataValidationRepository metadataValidationRepository; + + @Mock private AccessManager accessManager; @@ -70,6 +78,8 @@ public class MetadataPublicationServiceTest { @InjectMocks private MetadataPublicationService service; + + private AbstractMetadata mockMetadata() { AbstractMetadata metadata = mock(AbstractMetadata.class); when(metadata.getId()).thenReturn(METADATA_ID); @@ -149,4 +159,33 @@ public void allowsAdministratorWithoutCheckingPublicationProfile() throws Except // Administrators bypass the configured publication-profile check. verify(accessManager, never()).isProfileOnGroup(any(), any(), anyInt()); } + + + + /** + * Templates have {@code MetadataType.requiresValidation == false}, so the + * {@code checkCanPublishToAllGroup} gate must skip the validation repository entirely + * even when {@code allowPublishInvalidMd} is {@code false}. + */ + @Test + public void templateIsExemptFromValidationCheckWhenPublishInvalidMdDisabled() throws Exception { + AbstractMetadata template = mock(AbstractMetadata.class); + MetadataDataInfo dataInfo = mock(MetadataDataInfo.class); + when(dataInfo.getType()).thenReturn(MetadataType.TEMPLATE); + when(template.getDataInfo()).thenReturn(dataInfo); + + ServiceContext context = mock(ServiceContext.class); + ResourceBundle messages = mock(ResourceBundle.class); + + Method method = MetadataPublicationService.class.getDeclaredMethod( + "checkCanPublishToAllGroup", + ServiceContext.class, ResourceBundle.class, AbstractMetadata.class, boolean.class, boolean.class); + method.setAccessible(true); + + // Must not throw – templates are exempt from validation checks. + method.invoke(service, context, messages, template, false, true); + + // The validation repository must never be consulted for templates. + verify(metadataValidationRepository, never()).count(any(Specification.class)); + } } diff --git a/docs/manual/docs/administrator-guide/configuring-the-catalog/system-configuration.md b/docs/manual/docs/administrator-guide/configuring-the-catalog/system-configuration.md index 6463f93676d..b111dc2903f 100644 --- a/docs/manual/docs/administrator-guide/configuring-the-catalog/system-configuration.md +++ b/docs/manual/docs/administrator-guide/configuring-the-catalog/system-configuration.md @@ -356,6 +356,22 @@ Allows to configure the zip export of metadata records and their attachments. - **Total size of attachments allowed in zip export (MB)** Maximum total size of attachments allowed in zip export (in MB). If the total size of attachments linked to the selected metadata is above this value, exporting as zip (with attachments) is not allowed. Leave empty for no limit. +## Metadata workflow + +The following settings control what metadata can be published when the metadata workflow is active. They are found under `Administration` --> `Settings` --> `Metadata Workflow`. + +- **Allow publication of invalid metadata** + When enabled, metadata records can be published regardless of its validation status. + When disabled, only records that passe all required XSD and schematron validation rules can be published. + *This setting applies to metadata records only, metadata templates exempt from this check, as they are incomplete by design.* +- **Allow submission/approval of invalid metadata** + When enabled, records can be submitted or approved even when validation fails. + When disabled, records must be valid before they can move to `Submitted` or `Approved` workflow status. + *This setting applies to metadata records only, metadata templates exempt from this check, as they are incomplete by design.* +- **Allow publication of non-approved metadata** + When enabled, metadata records and templates can be published regardless of its workflow status. + When disabled, only metadata records and templates with an `Approved` workflow status can be published. + ## Harvesting *Allow editing on harvested records*: Enables/Disables editing of harvested records in the catalogue. By default, harvested records cannot be edited. diff --git a/domain/src/main/java/org/fao/geonet/domain/MetadataType.java b/domain/src/main/java/org/fao/geonet/domain/MetadataType.java index 372c04c26a6..148dcda7757 100644 --- a/domain/src/main/java/org/fao/geonet/domain/MetadataType.java +++ b/domain/src/main/java/org/fao/geonet/domain/MetadataType.java @@ -34,22 +34,22 @@ public enum MetadataType { /** * Indicates the associated {@link Metadata} entity is a normal metadata. */ - METADATA('n'), + METADATA('n', true), /** * Indicates the associated {@link Metadata} entity is a template metadata. */ - TEMPLATE('y'), + TEMPLATE('y', false), /** * Indicates the associated {@link Metadata} entity is a sub-template metadata.

A * sub-template is a metadata fragment that can be inserted into another metadata. It can also * be shared as an xlink in multiple metadata to reduce duplication. */ - SUB_TEMPLATE('s'), + SUB_TEMPLATE('s', false), /** * Indicates the associated {@link Metadata} entity is a template of sub template. */ - TEMPLATE_OF_SUB_TEMPLATE('t'); + TEMPLATE_OF_SUB_TEMPLATE('t', false); /** * The code (for backwards compatibility) of the metadatatype. @@ -59,10 +59,22 @@ public enum MetadataType { * Same as {@link #code} expect as a string instead of a char. */ public final String codeString; + /** + * Indicates if the metadata type requires validation. + * Templates do not require validation as they are incomplete by design. + */ + public final boolean requiresValidation; - private MetadataType(final char code) { + /** + * Constructor for the enum. + * + * @param code the code of the metadata type. + * @param requiresValidation indicates if the metadata type requires validation. + */ + MetadataType(final char code, final boolean requiresValidation) { this.code = code; this.codeString = String.valueOf(code); + this.requiresValidation = requiresValidation; } @Nonnull diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataWorkflowApi.java b/services/src/main/java/org/fao/geonet/api/records/MetadataWorkflowApi.java index 8d7e03dcec7..61dd692ae9f 100644 --- a/services/src/main/java/org/fao/geonet/api/records/MetadataWorkflowApi.java +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataWorkflowApi.java @@ -575,9 +575,10 @@ public Map setStatus(@Parameter(description = API_PAR boolean isAllowedSubmitApproveInvalidMd = settingManager .getValueAsBool(Settings.METADATA_WORKFLOW_ALLOW_SUBMIT_APPROVE_INVALID_MD); + boolean metadataTypeRequiresValidation = metadata.getDataInfo().getType().requiresValidation; if (((status.getStatus() == Integer.parseInt(StatusValue.Status.SUBMITTED)) || (status.getStatus() == Integer.parseInt(StatusValue.Status.APPROVED))) - && !isAllowedSubmitApproveInvalidMd) { + && !isAllowedSubmitApproveInvalidMd && metadataTypeRequiresValidation) { metadataValidator.doValidate(metadata, context.getLanguage()); boolean isInvalid = MetadataUtils.retrieveMetadataValidationStatus(metadata, context); @@ -1396,7 +1397,8 @@ private boolean isAllowedMetadataStatusChange(ServiceContext context, AbstractMe MetadataProcessingReport report) throws Exception { boolean isAllowedSubmitApproveInvalidMd = settingManager .getValueAsBool(Settings.METADATA_WORKFLOW_ALLOW_SUBMIT_APPROVE_INVALID_MD); - if (!isAllowedSubmitApproveInvalidMd) { + boolean metadataTypeRequiresValidation = metadata.getDataInfo().getType().requiresValidation; + if (!isAllowedSubmitApproveInvalidMd && metadataTypeRequiresValidation) { boolean isInvalid = MetadataUtils.retrieveMetadataValidationStatus(metadata, context); if (isInvalid) { diff --git a/services/src/main/java/org/fao/geonet/api/records/editing/MetadataEditingApi.java b/services/src/main/java/org/fao/geonet/api/records/editing/MetadataEditingApi.java index 326a64db857..0c6f0d4c857 100644 --- a/services/src/main/java/org/fao/geonet/api/records/editing/MetadataEditingApi.java +++ b/services/src/main/java/org/fao/geonet/api/records/editing/MetadataEditingApi.java @@ -390,9 +390,10 @@ public void saveEdits( // Automatically change the workflow state after save if (isEnabledWorkflow) { boolean isAllowedSubmitApproveInvalidMd = sm.getValueAsBool(Settings.METADATA_WORKFLOW_ALLOW_SUBMIT_APPROVE_INVALID_MD); + boolean metadataTypeRequiresValidation = metadata.getDataInfo().getType().requiresValidation; if (((status.equals(StatusValue.Status.SUBMITTED)) || (status.equals(StatusValue.Status.APPROVED))) - && !isAllowedSubmitApproveInvalidMd) { + && !isAllowedSubmitApproveInvalidMd && metadataTypeRequiresValidation) { if (!forceValidationOnMdSave) { validator.doValidate(metadata, context.getLanguage()); @@ -455,12 +456,13 @@ public void saveEdits( } boolean automaticUnpublishInvalidMd = sm.getValueAsBool(METADATA_WORKFLOW_AUTOMATIC_UNPUBLISH_INVALID_MD); + boolean metadataTypeRequiresValidation = metadata.getDataInfo().getType().requiresValidation; boolean isUnpublished = false; // Unpublish the metadata automatically if the setting // automaticUnpublishInvalidMd is enabled and // the metadata becomes invalid - if (automaticUnpublishInvalidMd) { + if (automaticUnpublishInvalidMd && metadataTypeRequiresValidation) { final OperationAllowedRepository operationAllowedRepo = context .getBean(OperationAllowedRepository.class); diff --git a/services/src/test/java/org/fao/geonet/api/records/MetadataSharingApiTest.java b/services/src/test/java/org/fao/geonet/api/records/MetadataSharingApiTest.java index e5ee8d9d129..9381a9a0e78 100644 --- a/services/src/test/java/org/fao/geonet/api/records/MetadataSharingApiTest.java +++ b/services/src/test/java/org/fao/geonet/api/records/MetadataSharingApiTest.java @@ -32,6 +32,10 @@ import org.fao.geonet.domain.Group; import org.fao.geonet.domain.GroupType; import org.fao.geonet.domain.Metadata; +import org.fao.geonet.domain.MetadataType; +import org.fao.geonet.domain.MetadataValidation; +import org.fao.geonet.domain.MetadataValidationId; +import org.fao.geonet.domain.MetadataValidationStatus; import org.fao.geonet.domain.OperationAllowed; import org.fao.geonet.domain.Profile; import org.fao.geonet.domain.ReservedGroup; @@ -44,7 +48,9 @@ import org.fao.geonet.kernel.setting.Settings; import org.fao.geonet.repository.MetadataRepository; import org.fao.geonet.repository.MetadataStatusRepository; +import org.fao.geonet.repository.MetadataValidationRepository; import org.fao.geonet.repository.OperationAllowedRepository; +import org.fao.geonet.repository.StatusValueRepository; import org.fao.geonet.repository.UserRepositoryTest; import org.fao.geonet.services.AbstractServiceIntegrationTest; import org.junit.Before; @@ -87,6 +93,12 @@ public class MetadataSharingApiTest extends AbstractServiceIntegrationTest { @Autowired private MetadataStatusRepository metadataStatusRepository; + @Autowired + private MetadataValidationRepository metadataValidationRepository; + + @Autowired + private StatusValueRepository statusValueRepository; + @Autowired private SettingManager settingManager; @@ -643,4 +655,41 @@ private void grantUserAdminInNewGroup(User user, String groupName) { .setProfile(Profile.UserAdmin) .setUser(user)); } + + /** + * Verifies that publishing a TEMPLATE succeeds even when invalid and {@code allowPublishInvalidMd} is {@code false}. + * Templates are exempt from validation checks during publish operations. + */ + @Test + public void publishInvalidTemplateSucceedsWhenPublishInvalidMdDisabled() throws Exception { + // Disable publishing of invalid metadata. + settingManager.setValue(Settings.METADATA_WORKFLOW_ALLOW_PUBLISH_INVALID_MD, false); + + // Change the metadata type to TEMPLATE. + Metadata template = metadataRepository.findById(metadataId).get(); + template.getDataInfo().setType(MetadataType.TEMPLATE); + metadataRepository.save(template); + + // Persist a required, INVALID validation record so the check would fail for normal metadata. + MetadataValidation invalidValidation = new MetadataValidation() + .setId(new MetadataValidationId(metadataId, "xsd")) + .setStatus(MetadataValidationStatus.INVALID) + .setRequired(true) + .setNumTests(1) + .setNumFailures(1); + metadataValidationRepository.save(invalidValidation); + + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession mockHttpSession = loginAs(reviewerUser); + + // Publishing the template must succeed (HTTP 204) despite the invalid validation record. + mockMvc.perform(put("/srv/api/records/" + metadataUuid + "/publish") + .session(mockHttpSession)) + .andExpect(status().isNoContent()); + + // Confirm publication privileges were actually granted. + List ops = operationAllowedRepository.findAllById_MetadataId(metadataId); + boolean published = ops.stream().anyMatch(op -> ReservedGroup.isReserved(op.getId().getGroupId())); + assertTrue("Template should be published even when it has an invalid validation record", published); + } } diff --git a/services/src/test/java/org/fao/geonet/api/records/MetadataWorkflowApiTest.java b/services/src/test/java/org/fao/geonet/api/records/MetadataWorkflowApiTest.java index 87ec40f129a..b20dabfece4 100644 --- a/services/src/test/java/org/fao/geonet/api/records/MetadataWorkflowApiTest.java +++ b/services/src/test/java/org/fao/geonet/api/records/MetadataWorkflowApiTest.java @@ -5,10 +5,13 @@ import jeeves.server.context.ServiceContext; import org.fao.geonet.api.ApiUtils; import org.fao.geonet.api.records.model.MetadataBatchSubmitParameter; +import org.fao.geonet.api.records.model.MetadataStatusParameter; import org.fao.geonet.domain.*; import org.fao.geonet.kernel.SelectionManager; import org.fao.geonet.kernel.setting.SettingManager; import org.fao.geonet.kernel.setting.Settings; +import org.fao.geonet.repository.MetadataRepository; +import org.fao.geonet.repository.MetadataValidationRepository; import org.fao.geonet.repository.MetadataStatusRepository; import org.fao.geonet.repository.StatusValueRepository; import org.fao.geonet.services.AbstractServiceIntegrationTest; @@ -38,20 +41,22 @@ public class MetadataWorkflowApiTest extends AbstractServiceIntegrationTest { @Autowired MetadataStatusRepository metadataStatusRepo; @Autowired - StatusValueRepository statusValueRepo; - @Autowired private SettingManager settingManager; @Autowired + private MetadataRepository metadataRepository; + @Autowired + private MetadataValidationRepository metadataValidationRepository; + @Autowired private StatusValueRepository statusValueRepository; private String uuid; private String uuid2; - private ServiceContext context; + private int metadataId; @Before public void setUp() throws Exception { - context = createServiceContext(); + ServiceContext context = createServiceContext(); loginAsAdmin(context); settingManager.setValue(Settings.METADATA_WORKFLOW_ENABLE, true); settingManager.setValue(Settings.METADATA_WORKFLOW_DRAFT_WHEN_IN_GROUP, ".*"); @@ -59,6 +64,7 @@ public void setUp() throws Exception { AbstractMetadata metadata = injectMetadataInDb(getSampleMetadataXml(), context); injectStatusForMetadata(metadata, DRAFT); uuid = metadata.getUuid(); + metadataId = metadata.getId(); AbstractMetadata metadata2 = injectMetadataInDb(getSampleMetadataXml(), context); injectStatusForMetadata(metadata2, APPROVED); uuid2 = metadata2.getUuid(); @@ -107,6 +113,45 @@ public void testBatchSubmit() throws Exception { .andExpect(jsonPath("$.numberOfRecordsProcessed").value(1)); } + @Test + public void testApproveInvalidTemplateSucceedsWhenAllowInvalidSubmissionApprovalDisabled() throws Exception { + settingManager.setValue(Settings.METADATA_WORKFLOW_ALLOW_SUBMIT_APPROVE_INVALID_MD, false); + + Metadata templateMetadata = metadataRepository.findOneByUuid(uuid); + templateMetadata.getDataInfo().setType(MetadataType.TEMPLATE); + metadataRepository.save(templateMetadata); + + MetadataValidation invalidValidation = new MetadataValidation() + .setId(new MetadataValidationId(metadataId, "xsd")) + .setStatus(MetadataValidationStatus.INVALID) + .setRequired(true) + .setNumTests(1) + .setNumFailures(1); + metadataValidationRepository.save(invalidValidation); + + MetadataStatusParameter statusParameter = new MetadataStatusParameter(); + statusParameter.setStatus(Integer.parseInt(APPROVED)); + statusParameter.setChangeMessage("approve template in test"); + + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession mockHttpSession = loginAsAdmin(); + Gson gson = new Gson(); + String json = gson.toJson(statusParameter); + + mockMvc.perform(put("/srv/api/records/" + uuid + "/status") + .content(json) + .contentType(API_JSON_EXPECTED_ENCODING) + .accept(MediaType.parseMediaType("application/json")) + .session(mockHttpSession)) + .andExpect(status().isCreated()); + + boolean approvedStatusCreated = metadataStatusRepo.findAllByMetadataId(metadataId, org.springframework.data.domain.Sort.unsorted()).stream() + .map(MetadataStatus::getStatusValue) + .map(StatusValue::getId) + .anyMatch(id -> id == Integer.parseInt(APPROVED)); + Assert.isTrue(approvedStatusCreated); + } + private void injectStatusForMetadata(AbstractMetadata metadata, String status) { MetadataStatus metadataStatus; Optional statusValue; diff --git a/web-ui/src/main/resources/catalog/locales/en-admin.json b/web-ui/src/main/resources/catalog/locales/en-admin.json index 641739f9c7f..fddade75c4a 100644 --- a/web-ui/src/main/resources/catalog/locales/en-admin.json +++ b/web-ui/src/main/resources/catalog/locales/en-admin.json @@ -935,11 +935,11 @@ "metadata/workflow/automaticUnpublishInvalidMd": "Automatic unpublication of invalid metadata", "metadata/workflow/automaticUnpublishInvalidMd-help": " Automatically unpublishes metadata that is edited that becomes not valid according to xsd or schematron rules.", "metadata/workflow/allowSubmitApproveInvalidMd": "Allow submission/approval of invalid metadata", - "metadata/workflow/allowSubmitApproveInvalidMd-help": "Allows the submission/approval of metadata that is not valid according to xsd or schematron rules.", + "metadata/workflow/allowSubmitApproveInvalidMd-help": "Allows the submission/approval of metadata record that is not valid according to xsd or schematron rules. This setting does not apply to meatadata templates.", "metadata/workflow/allowPublishNonApprovedMd": "Allow publication of non-approved metadata", "metadata/workflow/allowPublishNonApprovedMd-help": "Allows the publication of metadata that is not approved.", "metadata/workflow/allowPublishInvalidMd": "Allow publication of invalid metadata", - "metadata/workflow/allowPublishInvalidMd-help": "Allows the publication of metadata that is not valid according to xsd or schematron rules.", + "metadata/workflow/allowPublishInvalidMd-help": "Allows the publication of metadata record that is not valid according to xsd or schematron rules. This setting does not apply to metadata templates.", "metadata/workflow/draftWhenInGroup": "Activate workflow for record created in ", "metadata/workflow/enable": "Enable workflow", "draftWhenInGroup-all": "Any group",