diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php index c2c373f32fb..eebb06624ca 100644 --- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php +++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php @@ -15,5 +15,6 @@ use Symfony\Component\Validator\Constraint; class ModerationStateConstraint extends Constraint { public $message = 'Invalid state transition from %from to %to'; + public $invalidStateMessage = 'State %state does not exist on %workflow'; } diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php index b664c65d259..97ebc9328b0 100644 --- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php +++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php @@ -68,7 +68,7 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements * {@inheritdoc} */ public function validate($value, Constraint $constraint) { - /** @var \Drupal\Core\Entity\EntityInterface $entity */ + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $value->getEntity(); // Ignore entities that are not subject to moderation anyway. @@ -76,29 +76,43 @@ class ModerationStateConstraintValidator extends ConstraintValidator implements return; } - // Ignore entities that are being created for the first time. - if ($entity->isNew()) { - return; - } - - // Ignore entities that are being moderated for the first time, such as - // when they existed before moderation was enabled for this entity type. - if ($this->isFirstTimeModeration($entity)) { - return; - } - - $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); - if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) { - $original_entity = $original_entity->getTranslation($entity->language()->getId()); - } - $workflow = $this->moderationInformation->getWorkflowForEntity($entity); - $new_state = $workflow->getState($entity->moderation_state->value) ?: $workflow->getInitialState(); - $original_state = $workflow->getState($original_entity->moderation_state->value); - // @todo - what if $new_state references something that does not exist or - // is null. - if (!$original_state->canTransitionTo($new_state->id())) { - $this->context->addViolation($constraint->message, ['%from' => $original_state->label(), '%to' => $new_state->label()]); + + if (!$workflow->hasState($entity->moderation_state->value)) { + // If the state we are transitioning to doesn't exist, we can't validate + // the transitions for this entity further. + $this->context->addViolation($constraint->invalidStateMessage, [ + '%state' => $entity->moderation_state->value, + '%workflow' => $workflow->label(), + ]); + return; + } + + // If a new state is being set and there is an existing state, validate + // there is a valid transition between them. + if (!$entity->isNew() && !$this->isFirstTimeModeration($entity)) { + $original_entity = $this->entityTypeManager->getStorage($entity->getEntityTypeId())->loadRevision($entity->getLoadedRevisionId()); + if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) { + $original_entity = $original_entity->getTranslation($entity->language()->getId()); + } + + // If the state of the original entity doesn't exist on the workflow, + // we cannot do any further validation of transitions, because none will + // be setup for a state that doesn't exist. Instead allow any state to + // take its place. + if (!$workflow->hasState($original_entity->moderation_state->value)) { + return; + } + + $new_state = $workflow->getState($entity->moderation_state->value); + $original_state = $workflow->getState($original_entity->moderation_state->value); + + if (!$original_state->canTransitionTo($new_state->id())) { + $this->context->addViolation($constraint->message, [ + '%from' => $original_state->label(), + '%to' => $new_state->label() + ]); + } } } diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php index 7c4f97d391e..c8ce61b5657 100644 --- a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php @@ -96,6 +96,124 @@ class EntityStateChangeValidationTest extends KernelTestBase { $this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage()); } + /** + * Test validation with an invalid state. + */ + public function testInvalidState() { + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->moderation_state->value = 'invalid_state'; + $violations = $node->validate(); + + $this->assertCount(1, $violations); + $this->assertEquals('State invalid_state does not exist on Editorial workflow', $violations->get(0)->getMessage()); + } + + /** + * Test validation with content that has no initial state or an invalid state. + */ + public function testInvalidStateWithoutExisting() { + // Create content without moderation enabled for the content type. + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test title', + ]); + $node->save(); + + // Enable moderation to test validation on existing content, with no + // explicit state. + $workflow = Workflow::load('editorial'); + $workflow->addState('deleted_state', 'Deleted state'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + + // Validate the invalid state. + $node->moderation_state->value = 'invalid_state'; + $violations = $node->validate(); + $this->assertCount(1, $violations); + + // Assign the node to a state we're going to delete. + $node->moderation_state->value = 'deleted_state'; + $node->save(); + + // Delete the state so $node->original contains an invalid state when + // validating. + $workflow->deleteState('deleted_state'); + $workflow->save(); + $node->moderation_state->value = 'draft'; + $violations = $node->validate(); + $this->assertCount(0, $violations); + } + + /** + * Test state transition validation with multiple languages. + */ + public function testInvalidStateMultilingual() { + ConfigurableLanguage::createFromLangcode('fr')->save(); + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + + $node = Node::create([ + 'type' => 'example', + 'title' => 'English Published Node', + 'langcode' => 'en', + 'moderation_state' => 'published', + ]); + $node->save(); + + $node_fr = $node->addTranslation('fr'); + $node_fr->setTitle('French Published Node'); + $node_fr->save(); + $this->assertEquals('published', $node_fr->moderation_state->value); + + // Create a forward revision of the original node. + $node->moderation_state = 'draft'; + $node->setNewRevision(TRUE); + $node->isDefaultRevision(FALSE); + $node->save(); + + // For the forward english revision, there should be a violation from draft + // to archived. + $node->moderation_state = 'archived'; + $violations = $node->validate(); + $this->assertCount(1, $violations); + $this->assertEquals('Invalid state transition from Draft to Archived', $violations->get(0)->getMessage()); + + // From the default french published revision, there should be none. + $node_fr = Node::load($node->id())->getTranslation('fr'); + $this->assertEquals('published', $node_fr->moderation_state->value); + $node_fr->moderation_state = 'archived'; + $violations = $node_fr->validate(); + $this->assertCount(0, $violations); + + // From the latest french revision, there should also be no violation. + $node_fr = $node->getTranslation('fr'); + $this->assertEquals('published', $node_fr->moderation_state->value); + $node_fr->moderation_state = 'archived'; + $violations = $node_fr->validate(); + $this->assertCount(0, $violations); + } + /** * Tests that content without prior moderation information can be moderated. */