diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml index 904bc0dc33..256095a03a 100644 --- a/core/modules/content_moderation/content_moderation.services.yml +++ b/core/modules/content_moderation/content_moderation.services.yml @@ -19,4 +19,9 @@ services: class: Drupal\content_moderation\RevisionTracker arguments: ['@database'] tags: - - { name: backend_overridable } + - { name: backend_overridable } + content_moderation.config_import_subscriber: + class: Drupal\content_moderation\EventSubscriber\ConfigImportSubscriber + arguments: ['@config.manager', '@entity_type.manager'] + tags: + - { name: event_subscriber } diff --git a/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php b/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php new file mode 100644 index 0000000000..3555d736aa --- /dev/null +++ b/core/modules/content_moderation/src/EventSubscriber/ConfigImportSubscriber.php @@ -0,0 +1,72 @@ +configManager = $config_manager; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function onConfigImporterValidate(ConfigImporterEvent $event) { + $unprocessed_configurations = $event->getConfigImporter()->getUnprocessedConfiguration('update'); + foreach ($unprocessed_configurations as $unprocessed_configuration) { + $entity_type_id = $this->configManager->getEntityTypeIdByName($unprocessed_configuration); + if ($entity_type_id == 'workflow') { + /** @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $entity_type */ + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $importer = $event->getConfigImporter(); + $source_storage = $importer->getStorageComparer() + ->getSourceStorage() + ->read($unprocessed_configuration); + $target_storage = $importer->getStorageComparer() + ->getTargetStorage() + ->read($unprocessed_configuration); + $diff = array_diff_key($target_storage['states'], $source_storage['states']); + $entity_id = ConfigEntityStorage::getIDFromConfigName($unprocessed_configuration, $entity_type->getConfigPrefix()); + /** @var \Drupal\workflows\WorkflowInterface $workflow */ + $workflow = $this->entityTypeManager->getStorage($entity_type_id)->load($entity_id); + $state = $workflow->getState(key($diff)); + if ($workflow->getTypePlugin()->isWorkflowStateUsed($workflow, $state)) { + $importer->logError($this->t('The moderation state @state_label is being used, but is not in the source storage.', ['@state_label' => $state->label()])); + } + } + } + } + +} diff --git a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php index b80ccdda87..dcfe341b34 100644 --- a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php +++ b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php @@ -131,6 +131,31 @@ public function decorateState(StateInterface $state) { /** * {@inheritdoc} */ + public function isWorkflowUsed(WorkflowInterface $workflow) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->count() + ->condition('workflow', $workflow->id()) + ->execute(); + } + + /** + * {@inheritdoc} + */ + public function isWorkflowStateUsed(WorkflowInterface $workflow, StateInterface $state) { + return (bool) $this->entityTypeManager + ->getStorage('content_moderation_state') + ->getQuery() + ->count() + ->condition('workflow', $workflow->id()) + ->condition('moderation_state', $state->id()) + ->execute(); + } + + /** + * {@inheritdoc} + */ public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { /** @var \Drupal\content_moderation\ContentModerationState $state */ $is_required_state = isset($state) ? in_array($state->id(), $this->getRequiredStates(), TRUE) : FALSE; diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php index 3882c764f1..6e34430e7f 100644 --- a/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationFormTest.php @@ -205,4 +205,41 @@ public function testModerationFormSetsRevisionAuthor() { $this->assertText('by ' . $another_user->getAccountName()); } + /** + * Tests ContentModeration::isWorkflowUsed(). + * + * @see \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration::isWorkflowUsed() + */ + public function testWorkflowInUse() { + $this->drupalLogin($this->rootUser); + $paths = [ + 'archived_state' => 'admin/config/workflow/workflows/manage/editorial/state/archived/delete', + 'editorial_workflow' => 'admin/config/workflow/workflows/manage/editorial/delete', + ]; + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(200); + } + // Create new moderated content in draft. + $this->drupalPostForm('node/add/moderated_content', [ + 'title[0][value]' => 'Some moderated content', + 'body[0][value]' => 'First version of the content.', + ], t('Save and Create New Draft')); + // The archived state is not used yet, so can still be deleted. + $this->drupalGet($paths['archived_state']); + $this->assertSession()->statusCodeEquals(200); + // The workflow is being used, so can't be deleted. + $this->drupalGet($paths['editorial_workflow']); + $this->assertSession()->statusCodeEquals(403); + + $node = $this->drupalGetNodeByTitle('Some moderated content'); + $edit_path = sprintf('node/%d/edit', $node->id()); + $this->drupalPostForm($edit_path, [], t('Save and Publish')); + $this->drupalPostForm($edit_path, [], t('Save and Archive')); + // Now the archived state is being used it can't be deleted either. + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(403); + } + } } diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php new file mode 100644 index 0000000000..97d9596d2b --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php @@ -0,0 +1,125 @@ +installSchema('node', 'node_access'); + $this->installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('content_moderation_state'); + $this->installConfig('content_moderation'); + + NodeType::create([ + 'type' => 'example', + ])->save(); + + $workflow = Workflow::load('editorial'); + $workflow->addState('test1', 'Test one'); + $workflow->addState('test2', 'Test two'); + $workflow->getTypePlugin()->setConfiguration([ + 'states' => [ + 'test1' => [ + 'published' => TRUE, + 'default_revision' => TRUE, + ], + 'test2' => [ + 'published' => TRUE, + 'default_revision' => TRUE, + ], + ], + ]); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $this->workflow = $workflow; + + $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); + + } + + /** + * Test deleting a state via config import. + */ + public function testDeletingStateViaConfiguration() { + $config_data = $this->config('workflows.workflow.editorial')->get(); + unset($config_data['states']['test1']); + \Drupal::service('config.storage.sync')->write('workflows.workflow.editorial', $config_data); + + // There are no Nodes with the moderation state test, so this should run + // with no errors. + $this->configImporter()->reset()->import(); + + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + 'moderation_state' => 'test2', + ]); + $node->save(); + + $config_data = $this->config('workflows.workflow.editorial')->get(); + unset($config_data['states']['test2']); + \Drupal::service('config.storage.sync')->write('workflows.workflow.editorial', $config_data); + + // Now there is a Node with the moderation state unpub_test, this will fail. + try { + $this->configImporter()->reset()->import(); + $this->fail('ConfigImporterException not thrown, invalid import was not stopped due to deleted state.'); + } + catch (ConfigImporterException $e) { + $this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.'); + $error_log = $this->configImporter->getErrors(); + $expected = ['The moderation state Test two is being used, but is not in the source storage.']; + $this->assertEqual($expected, $error_log); + } + } + +} diff --git a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php index 34af735e3d..4b9ca7f5be 100644 --- a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php +++ b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php @@ -58,6 +58,20 @@ public function checkWorkflowAccess(WorkflowInterface $entity, $operation, Accou /** * {@inheritdoc} */ + public function isWorkflowUsed(WorkflowInterface $workflow) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function isWorkflowStateUsed(WorkflowInterface $workflow, StateInterface $state) { + return FALSE; + } + + /** + * {@inheritdoc} + */ public function decorateState(StateInterface $state) { return $state; } diff --git a/core/modules/workflows/src/WorkflowAccessControlHandler.php b/core/modules/workflows/src/WorkflowAccessControlHandler.php index 156f0091cc..ea5aad4b8a 100644 --- a/core/modules/workflows/src/WorkflowAccessControlHandler.php +++ b/core/modules/workflows/src/WorkflowAccessControlHandler.php @@ -65,11 +65,15 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter $admin_access = AccessResult::allowedIf(count($entity->getStates()) > 1) ->andIf(parent::checkAccess($entity, 'edit', $account)) ->andIf(AccessResult::allowedIf(!in_array($state_id, $workflow_type->getRequiredStates(), TRUE))) + ->andIf(AccessResult::allowedIf(!$workflow_type->isWorkflowStateUsed($entity, $entity->getState($state_id)))) ->addCacheableDependency($entity); } else { $admin_access = parent::checkAccess($entity, $operation, $account); } + if ($operation === 'delete') { + $admin_access = $admin_access->orIf(AccessResult::forbiddenIf($workflow_type->isWorkflowUsed($entity)))->setCacheMaxAge(0); + } return $workflow_type->checkWorkflowAccess($entity, $operation, $account)->orIf($admin_access); } diff --git a/core/modules/workflows/src/WorkflowTypeInterface.php b/core/modules/workflows/src/WorkflowTypeInterface.php index 2412bf99f4..159fe17fc0 100644 --- a/core/modules/workflows/src/WorkflowTypeInterface.php +++ b/core/modules/workflows/src/WorkflowTypeInterface.php @@ -57,6 +57,30 @@ public function label(); public function checkWorkflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account); /** + * Determines if the workflow is being used. + * + * @param \Drupal\workflows\WorkflowInterface $workflow + * The workflow to check. + * + * @return bool + * TRUE if the workflow is being used, FALSE if not. + */ + public function isWorkflowUsed(WorkflowInterface $workflow); + + /** + * Determines if the workflow state is being used. + * + * @param \Drupal\workflows\WorkflowInterface $workflow + * The workflow to check. + * @param \Drupal\workflows\StateInterface $state + * The workflow state to check. + * + * @return bool + * TRUE if the workflow state is being used, FALSE if not. + */ + public function isWorkflowStateUsed(WorkflowInterface $workflow, StateInterface $state); + + /** * Decorates states so the WorkflowType can add additional information. * * @param \Drupal\workflows\StateInterface $state