From 1e5459ebf2b84a15bdce3ae7af4f1f001072f3d0 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Thu, 1 Nov 2018 14:23:04 +0000
Subject: [PATCH] Issue #2943899 by Sam152, amateescu, tstoeckler: Moderation
 state field cannot be updated via REST, because special handling in
 ModerationStateFieldItemList

---
 .../FieldWidget/ModerationStateWidget.php     |  2 +-
 .../Field/ModerationStateFieldItemList.php    | 18 +----
 .../ModerationStateConstraintValidator.php    |  8 ++
 .../ModerationStateFieldItemListTest.php      | 63 ++++++++++++++--
 .../ModeratedNodeJsonAnonTest.php             | 24 ++++++
 .../ModeratedNodeJsonBasicAuthTest.php        | 34 +++++++++
 .../ModeratedNodeJsonCookieTest.php           | 29 ++++++++
 .../ModeratedNodeResourceTestBase.php         | 74 +++++++++++++++++++
 .../ModeratedNodeXmlAnonTest.php              | 34 +++++++++
 .../ModeratedNodeXmlBasicAuthTest.php         | 44 +++++++++++
 .../ModeratedNodeXmlCookieTest.php            | 39 ++++++++++
 11 files changed, 345 insertions(+), 24 deletions(-)
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonAnonTest.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonBasicAuthTest.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonCookieTest.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeResourceTestBase.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlAnonTest.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlBasicAuthTest.php
 create mode 100644 core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlCookieTest.php

diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
index 8a33c76ed8a8..0d4c43a33343 100644
--- a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
+++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
@@ -127,7 +127,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen
     $transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
 
     $transition_labels = [];
-    $default_value = NULL;
+    $default_value = $items->value;
     foreach ($transitions as $transition) {
       $transition_to_state = $transition->to();
       $transition_labels[$transition_to_state->id()] = $transition_to_state->label();
diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
index c33ccacdeec1..64c5ad849796 100644
--- a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
+++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
@@ -16,7 +16,6 @@
 class ModerationStateFieldItemList extends FieldItemList {
 
   use ComputedItemListTrait {
-    ensureComputedValue as traitEnsureComputedValue;
     get as traitGet;
   }
 
@@ -34,19 +33,6 @@ protected function computeValue() {
     }
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  protected function ensureComputedValue() {
-    // If the moderation state field is set to an empty value, always recompute
-    // the state. Empty is not a valid moderation state value, when none is
-    // present the default state is used.
-    if (!isset($this->list[0]) || $this->list[0]->isEmpty()) {
-      $this->valueComputed = FALSE;
-    }
-    $this->traitEnsureComputedValue();
-  }
-
   /**
    * Gets the moderation state ID linked to a content entity revision.
    *
@@ -140,10 +126,8 @@ public function onChange($delta) {
    */
   public function setValue($values, $notify = TRUE) {
     parent::setValue($values, $notify);
+    $this->valueComputed = TRUE;
 
-    if (isset($this->list[0])) {
-      $this->valueComputed = TRUE;
-    }
     // If the parent created a field item and if the parent should be notified
     // about the change (e.g. this is not initialized with the current value),
     // update the moderated entity.
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 c3b9c815fe05..7894cadf752c 100644
--- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\content_moderation\ModerationInformationInterface;
 use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Validation\Plugin\Validation\Constraint\NotNullConstraint;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\Validator\Constraint;
 use Symfony\Component\Validator\ConstraintValidator;
@@ -89,6 +90,13 @@ public function validate($value, Constraint $constraint) {
       return;
     }
 
+    // If the entity is moderated and the item list is empty, ensure users see
+    // the same required message as typical NotNull constraints.
+    if ($value->isEmpty()) {
+      $this->context->addViolation((new NotNullConstraint())->message);
+      return;
+    }
+
     $workflow = $this->moderationInformation->getWorkflowForEntity($entity);
 
     if (!$workflow->getTypePlugin()->hasState($entity->moderation_state->value)) {
diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php
index 844e77d93082..b33ac6af511c 100644
--- a/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php
+++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php
@@ -103,20 +103,70 @@ public function testGet() {
   }
 
   /**
-   * Tests the computed field when it is unset or set to an empty value.
+   * Tests the item list when it is emptied and appended to.
    */
-  public function testSetEmptyState() {
+  public function testEmptyStateAndAppend() {
+    // This test case mimics the lifecycle of an entity that is being patched in
+    // a rest resource.
+    $this->testNode->moderation_state->setValue([]);
+    $this->assertTrue($this->testNode->moderation_state->isEmpty());
+    $this->assertEmptiedModerationFieldItemList();
+
+    $this->testNode->moderation_state->appendItem();
+    $this->assertEquals(1, $this->testNode->moderation_state->count());
+    $this->assertEquals(NULL, $this->testNode->moderation_state->value);
+    $this->assertEmptiedModerationFieldItemList();
+  }
+
+  /**
+   * Test an empty value assigned to the field item.
+   */
+  public function testEmptyFieldItem() {
     $this->testNode->moderation_state->value = '';
-    $this->assertEquals('draft', $this->testNode->moderation_state->value);
+    $this->assertEquals('', $this->testNode->moderation_state->value);
+    $this->assertEmptiedModerationFieldItemList();
+  }
 
+  /**
+   * Test an empty value assigned to the field item list.
+   */
+  public function testEmptyFieldItemList() {
     $this->testNode->moderation_state = '';
-    $this->assertEquals('draft', $this->testNode->moderation_state->value);
+    $this->assertEquals('', $this->testNode->moderation_state->value);
+    $this->assertEmptiedModerationFieldItemList();
+  }
 
+  /**
+   * Test the field item when it is unset.
+   */
+  public function testUnsetItemList() {
     unset($this->testNode->moderation_state);
-    $this->assertEquals('draft', $this->testNode->moderation_state->value);
+    $this->assertEquals(NULL, $this->testNode->moderation_state->value);
+    $this->assertEmptiedModerationFieldItemList();
+  }
 
+  /**
+   * Test the field item when it is assigned NULL.
+   */
+  public function testAssignNullItemList() {
     $this->testNode->moderation_state = NULL;
-    $this->assertEquals('draft', $this->testNode->moderation_state->value);
+    $this->assertEquals(NULL, $this->testNode->moderation_state->value);
+    $this->assertEmptiedModerationFieldItemList();
+  }
+
+  /**
+   * Assert the set of expectations when the moderation state field is emptied.
+   */
+  protected function assertEmptiedModerationFieldItemList() {
+    $this->assertTrue($this->testNode->moderation_state->isEmpty());
+    // Test the empty value causes a violation in the entity.
+    $violations = $this->testNode->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('This value should not be null.', $violations->get(0)->getMessage());
+    // Test that incorrectly saving the entity regardless will not produce a
+    // change in the moderation state.
+    $this->testNode->save();
+    $this->assertEquals('draft', Node::load($this->testNode->id())->moderation_state->value);
   }
 
   /**
@@ -132,6 +182,7 @@ public function testNonModeratedEntity() {
 
     $unmoderated_node->moderation_state = NULL;
     $this->assertEquals(0, $unmoderated_node->moderation_state->count());
+    $this->assertCount(0, $unmoderated_node->validate());
   }
 
   /**
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonAnonTest.php
new file mode 100644
index 000000000000..f28788ac8018
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonAnonTest.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeJsonAnonTest extends ModeratedNodeResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonBasicAuthTest.php
new file mode 100644
index 000000000000..b47a86f65ffb
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonBasicAuthTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeJsonBasicAuthTest extends ModeratedNodeResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonCookieTest.php
new file mode 100644
index 000000000000..08fc4b5f4fb0
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeJsonCookieTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeJsonCookieTest extends ModeratedNodeResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeResourceTestBase.php
new file mode 100644
index 000000000000..054259dbc561
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeResourceTestBase.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
+use Drupal\Tests\node\Functional\Rest\NodeResourceTestBase;
+
+/**
+ * Extend the Node resource test base and apply moderation to the entity.
+ */
+abstract class ModeratedNodeResourceTestBase extends NodeResourceTestBase {
+
+  use ContentModerationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['content_moderation'];
+
+  /**
+   * The test editorial workflow.
+   *
+   * @var \Drupal\workflows\WorkflowInterface
+   */
+  protected $workflow;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    parent::setUpAuthorization($method);
+
+    switch ($method) {
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['use editorial transition publish', 'use editorial transition create_new_draft']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity = parent::createEntity();
+    if (!$this->workflow) {
+      $this->workflow = $this->createEditorialWorkflow();
+    }
+    $this->workflow->getTypePlugin()->addEntityTypeAndBundle($entity->getEntityTypeId(), $entity->bundle());
+    $this->workflow->save();
+
+    return $entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return array_merge(parent::getExpectedNormalizedEntity(), [
+      'moderation_state' => [
+        [
+          'value' => 'published',
+        ],
+      ],
+      'vid' => [
+        [
+          'value' => (int) $this->entity->getRevisionId(),
+        ],
+      ],
+    ]);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlAnonTest.php
new file mode 100644
index 000000000000..4b91d766c6f4
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlAnonTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeXmlAnonTest extends ModeratedNodeResourceTestBase {
+
+  use AnonResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlBasicAuthTest.php
new file mode 100644
index 000000000000..a321e03cb25f
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlBasicAuthTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeXmlBasicAuthTest extends ModeratedNodeResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlCookieTest.php
new file mode 100644
index 000000000000..2014a56ff43d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ModeratedNode/ModeratedNodeXmlCookieTest.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * @group rest
+ */
+class ModeratedNodeXmlCookieTest extends ModeratedNodeResourceTestBase {
+
+  use CookieResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
-- 
GitLab