diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d0758f347c934fbbcb96688e407840df67d50ff0
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase;
+
+/**
+ * @group hal
+ */
+class BlockHalJsonAnonTest extends BlockResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..81ebf551bb6d3dd658b535676daa6a6cd95ebeab
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Block;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase;
+
+/**
+ * @group hal
+ */
+class BlockHalJsonBasicAuthTest extends BlockResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal', 'basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1403ebef7889faf44610d3f4c2e8b7a59930fe28
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase;
+
+/**
+ * @group hal
+ */
+class BlockHalJsonCookieTest extends BlockResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3edd9b16098a253822a221fddbed5cdfe405dca9
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Comment;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class CommentHalJsonAnonTest extends CommentHalJsonTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   *
+   * Anononymous users cannot edit their own comments.
+   *
+   * @see \Drupal\comment\CommentAccessControlHandler::checkAccess
+   *
+   * Therefore we grant them the 'administer comments' permission for the
+   * purpose of this test. Then they are able to edit their own comments, but
+   * some fields are still not editable, even with that permission.
+   *
+   * @see ::setUpAuthorization
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed',
+    'thread',
+    'entity_type',
+    'field_name',
+    'entity_id',
+  ];
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b6e4a3f778fbc54f78e21dc5474f137b7fee026
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Comment;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class CommentHalJsonBasicAuthTest extends CommentHalJsonTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..292dc94b64fb05125eeb28b2febda4bde708bd46
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Comment;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class CommentHalJsonCookieTest extends CommentHalJsonTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f4b03af46961e2b42e9b4116630545a4543c1f15
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Comment;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase;
+use Drupal\user\Entity\User;
+
+abstract class CommentHalJsonTestBase extends CommentResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   *
+   * The HAL+JSON format causes different PATCH-protected fields. For some
+   * reason, the 'pid' and 'homepage' fields are NOT PATCH-protected, even
+   * though they are for non-HAL+JSON serializations.
+   *
+   * @todo fix in https://www.drupal.org/node/2824271
+   */
+  protected static $patchProtectedFieldNames = [
+    'created',
+    'changed',
+    'status',
+    'thread',
+    'entity_type',
+    'field_name',
+    'entity_id',
+    'uid',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    // Because \Drupal\comment\Entity\Comment::getOwner() generates an in-memory
+    // User entity without a UUID, we cannot use it.
+    $author = User::load($this->entity->getOwnerId());
+    $commented_entity = EntityTest::load(1);
+    return  $normalization + [
+      '_links' => [
+        'self' => [
+          'href' => $this->baseUrl . '/comment/1?_format=hal_json',
+        ],
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/comment/comment',
+        ],
+        $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [
+          [
+            'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+          ],
+        ],
+        $this->baseUrl . '/rest/relation/comment/comment/uid' => [
+          [
+            'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+            'lang' => 'en',
+          ],
+        ],
+      ],
+      '_embedded' => [
+        $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/entity_test/bar',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $commented_entity->uuid()]
+            ],
+          ],
+        ],
+        $this->baseUrl . '/rest/relation/comment/comment/uid' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/user/user',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $author->uuid()]
+            ],
+            'lang' => 'en',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/comment/comment',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // The 'url.site' cache context is added for '_links' in the response.
+    return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']);
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..45cedb2a98e8ec5179398a9327e8a7a8ce4d8173
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase;
+
+/**
+ * @group hal
+ */
+class ConfigTestHalJsonAnonTest extends ConfigTestResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..489a3a552023988642ee42d5283e2a97d120d8c9
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase;
+
+/**
+ * @group hal
+ */
+class ConfigTestHalJsonBasicAuthTest extends ConfigTestResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal', 'basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..846aa9f05dc71f8591915b69267d1b5cdaeb1bab
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase;
+
+/**
+ * @group hal
+ */
+class ConfigTestHalJsonCookieTest extends ConfigTestResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fd02f12978c424fe33565dbc850a0414ee27a2b0
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase;
+use Drupal\user\Entity\User;
+
+/**
+ * @group hal
+ */
+class EntityTestHalJsonAnonTest extends EntityTestResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    $author = User::load(0);
+    return  $normalization + [
+      '_links' => [
+        'self' => [
+          'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+        ],
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/entity_test/entity_test',
+        ],
+        $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [
+          [
+            'href' => $this->baseUrl . '/user/0?_format=hal_json',
+            'lang' => 'en',
+          ],
+        ],
+      ],
+      '_embedded' => [
+        $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/user/0?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/user/user',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $author->uuid()]
+            ],
+            'lang' => 'en',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/entity_test/entity_test',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // The 'url.site' cache context is added for '_links' in the response.
+    return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']);
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5604e3b548c4d7a691f3d8df4a7c25e93ad37f98
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class EntityTestHalJsonBasicAuthTest extends EntityTestHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..efca3178e35a0d9b31c07076c7d5ab279f81b90e
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class EntityTestHalJsonCookieTest extends EntityTestHalJsonAnonTest {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..d3bd85a9a9dfe7f84a071d43b1c1cb0625c6f1a0
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource;
+
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Url;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * Trait for EntityResourceTestBase subclasses testing formats using HAL.
+ */
+trait HalEntityNormalizationTrait {
+
+  /**
+   * Applies the HAL entity field normalization to an entity normalization.
+   *
+   * The HAL normalization:
+   * - adds a 'lang' attribute to every translatable field
+   * - omits reference fields, since references are stored in _links & _embedded
+   * - omits empty fields (fields without value)
+   *
+   * @param array $normalization
+   *   An entity normalization.
+   *
+   * @return array
+   *   The updated entity normalization.
+   */
+  protected function applyHalFieldNormalization(array $normalization) {
+    if (!$this->entity instanceof FieldableEntityInterface) {
+      throw new \LogicException('This trait should only be used for fieldable entity types.');
+    }
+
+    // In the HAL normalization, all translatable fields get a 'lang' attribute.
+    $translatable_non_reference_fields = array_keys(array_filter($this->entity->getTranslatableFields(), function (FieldItemListInterface $field) {
+      return !$field instanceof EntityReferenceFieldItemListInterface;
+    }));
+    foreach ($translatable_non_reference_fields as $field_name) {
+      if (isset($normalization[$field_name])) {
+        $normalization[$field_name][0]['lang'] = 'en';
+      }
+    }
+
+    // In the HAL normalization, reference fields are omitted, except for the
+    // bundle field.
+    $bundle_key = $this->entity->getEntityType()->getKey('bundle');
+    $reference_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) use ($bundle_key) {
+      return $field instanceof EntityReferenceFieldItemListInterface && $field->getName() !== $bundle_key;
+    }));
+    foreach ($reference_fields as $field_name) {
+      unset($normalization[$field_name]);
+    }
+
+    // In the HAL normalization, the bundle field  omits the 'target_type' and
+    // 'target_uuid' properties, because it's encoded in the '_links' section.
+    if ($bundle_key) {
+      unset($normalization[$bundle_key][0]['target_type']);
+      unset($normalization[$bundle_key][0]['target_uuid']);
+    }
+
+    // In the HAL normalization, empty fields are omitted.
+    $empty_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) {
+      return $field->isEmpty();
+    }));
+    foreach ($empty_fields as $field_name) {
+      unset($normalization[$field_name]);
+    }
+
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function removeFieldsFromNormalization(array $normalization, $field_names) {
+    $normalization = parent::removeFieldsFromNormalization($normalization, $field_names);
+    foreach ($field_names as $field_name) {
+      $relation_url = Url::fromUri('base:rest/relation/' . static::$entityTypeId . '/' . $this->entity->bundle() . '/' . $field_name)
+        ->setAbsolute(TRUE)
+        ->toString();
+      $normalization['_links'] = array_diff_key($normalization['_links'], [$relation_url => TRUE]);
+      if (isset($normalization['_embedded'])) {
+        $normalization['_embedded'] = array_diff_key($normalization['_embedded'], [$relation_url => TRUE]);
+      }
+    }
+
+    return array_diff_key($normalization, array_flip($field_names));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
+    // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
+    // types with bundles MUST send their bundle field to be denormalizable.
+    if ($this->entity->getEntityType()->hasKey('bundle')) {
+      $normalization = $this->getNormalizedPostEntity();
+
+      // @todo Uncomment this in https://www.drupal.org/node/2824827.
+      // @codingStandardsIgnoreStart
+/*
+      $normalization['_links']['type'] = Url::fromUri('base:rest/type/' . static::$entityTypeId . '/bad_bundle_name');
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+      // DX: 400 when incorrect entity type bundle is specified.
+      $response = $this->request($method, $url, $request_options);
+      // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+//      $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response);
+      $this->assertSame(400, $response->getStatusCode());
+      $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+      $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody());
+*/
+      // @codingStandardsIgnoreEnd
+
+      unset($normalization['_links']['type']);
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+      // DX: 400 when no entity type bundle is specified.
+      $response = $this->request($method, $url, $request_options);
+      // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+      // $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response);
+      $this->assertSame(400, $response->getStatusCode());
+      $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+      $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody());
+    }
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7f242812e4ade89897f76af84a0134626beaa3a
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Node;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase;
+use Drupal\user\Entity\User;
+
+/**
+ * @group hal
+ */
+class NodeHalJsonAnonTest extends NodeResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'created',
+    'changed',
+    'promote',
+    'sticky',
+    'revision_timestamp',
+    'revision_uid',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    $author = User::load($this->entity->getOwnerId());
+    return  $normalization + [
+      '_links' => [
+        'self' => [
+          'href' => $this->baseUrl . '/node/1?_format=hal_json',
+        ],
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/node/camelids',
+        ],
+        $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+          [
+            'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+            'lang' => 'en',
+          ],
+        ],
+        $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+          [
+            'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+          ],
+        ],
+      ],
+      '_embedded' => [
+        $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/user/user',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $author->uuid()]
+            ],
+            'lang' => 'en',
+          ],
+        ],
+        $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/user/user',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $author->uuid()]
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/node/camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // The 'url.site' cache context is added for '_links' in the response.
+    return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1d7bb62349720dd299415d9e3443949e4bbc9b13
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Node;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class NodeHalJsonBasicAuthTest extends NodeHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..19ed3637c4a4fb273e2dfa0b1abfc3d2ae47cf9d
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class NodeHalJsonCookieTest extends NodeHalJsonAnonTest {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..cad41042b05e58b13f9a9a20bb1c1f449a9c8128
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase;
+
+/**
+ * @group hal
+ */
+class RoleHalJsonAnonTest extends RoleResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f540bb1e60d8ac673225561ee01a65a684eab5e0
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Role;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase;
+
+/**
+ * @group hal
+ */
+class RoleHalJsonBasicAuthTest extends RoleResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal', 'basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b52713c50bc1c2a1ae7773e4e3c455f75302acaf
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase;
+
+/**
+ * @group hal
+ */
+class RoleHalJsonCookieTest extends RoleResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c794ae4b73d936a884bfc26c063eb7c56a1a1a8
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Term;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase;
+
+/**
+ * @group hal
+ */
+class TermHalJsonAnonTest extends TermResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    return $normalization + [
+      '_links' => [
+        'self' => [
+          'href' => $this->baseUrl . '/taxonomy/term/1?_format=hal_json',
+        ],
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // The 'url.site' cache context is added for '_links' in the response.
+    return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c7b04b651a9f345ff1a45218c8c8e5dbcdf63c8
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Term;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class TermHalJsonBasicAuthTest extends TermHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..23ccd20526f3deffae6424461466993da7ff0454
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class TermHalJsonCookieTest extends TermHalJsonAnonTest {
+
+  use CookieResourceTestTrait;
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d839a722b1c7c34fe13cabb41e17b1ba84064fce
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\User;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\User\UserResourceTestBase;
+
+/**
+ * @group hal
+ */
+class UserHalJsonAnonTest extends UserResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    return $normalization + [
+      '_links' => [
+        'self' => [
+          'href' => $this->baseUrl . '/user/3?_format=hal_json',
+        ],
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/user/user',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return parent::getNormalizedPostEntity() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/user/user',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // The 'url.site' cache context is added for '_links' in the response.
+    return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..dbf17cb54ce474bce2acb9b4c8f7a288eb277ed5
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\User;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class UserHalJsonBasicAuthTest extends UserHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b4a121b53fe607b43dc10798dfcbb699d90d902f
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\User;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group hal
+ */
+class UserHalJsonCookieTest extends UserHalJsonAnonTest {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..037ecc75174bfd4a7ce361f47d45cd1caec21145
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase;
+
+/**
+ * @group hal
+ */
+class VocabularyHalJsonAnonTest extends VocabularyResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * @todo Remove this override in https://www.drupal.org/node/2805281.
+   */
+  public function testGet() {
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4f7896e9bb352d6196df085539f7298c97954b85
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase;
+
+/**
+ * @group hal
+ */
+class VocabularyHalJsonBasicAuthTest extends VocabularyResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal', 'basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b751cdd2200183464d023fbd566f7712ef20e26d
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase;
+
+/**
+ * @group hal
+ */
+class VocabularyHalJsonCookieTest extends VocabularyResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['hal'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'hal_json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/hal+json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef77d96ceaba5f2332485d7b4ae9faaf3a4c4346
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional;
+
+use Psr\Http\Message\ResponseInterface;
+
+trait HalJsonBasicAuthWorkaroundFor2805281Trait {
+
+  /**
+   * {@inheritdoc}
+   *
+   * Note how the response claims it contains a application/hal+json body, but
+   * in reality it contains a text/plain body! Also, the correct error MIME type
+   * is application/json.
+   *
+   * @todo Fix in https://www.drupal.org/node/2805281: remove this trait.
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertSame(401, $response->getStatusCode());
+    // @todo this works fine locally, but on testbot it comes back with
+    // 'text/plain; charset=UTF-8'. WTF.
+    // $this->assertSame(['application/hal+json'], $response->getHeader('Content-Type'));
+    $this->assertSame('No authentication credentials provided.', (string) $response->getBody());
+  }
+
+}
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cf9efee46b35a917b2b0d8e2ed744cd7d34567ce
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml
@@ -0,0 +1,7 @@
+name: 'Configuration test REST'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - config_test
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module
new file mode 100644
index 0000000000000000000000000000000000000000..fcd9979a11ab5b4479b526c41507e702bf8143eb
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for testing REST module.
+ */
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function config_test_rest_entity_type_alter(array &$entity_types) {
+  // Undo part of what config_test_entity_type_alter() did: remove this
+  // config_test_no_status entity type, because it uses the same entity class as
+  // the config_test entity type, which makes REST deserialization impossible.
+  unset($entity_types['config_test_no_status']);
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_access().
+ */
+function config_test_rest_config_test_access(EntityInterface $entity, $operation, AccountInterface $account) {
+  // Add permission, so that EntityResourceTestBase's scenarios can test access
+  // being denied. By default, all access is always allowed for the config_test
+  // config entity.
+  return AccessResult::forbiddenIf(!$account->hasPermission('view config_test'))->cachePerPermissions();
+}
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b8fd229fd3b1b7d27fcbfd76945a9d5b6c742c86
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml
@@ -0,0 +1,2 @@
+view config_test:
+  title: 'View ConfigTest entities'
diff --git a/core/modules/rest/tests/modules/rest_test/rest_test.module b/core/modules/rest/tests/modules/rest_test/rest_test.module
index 272603d157fdb9af016b5778c7269b1410b1230b..01511ea1e0ba74ff458459104aae9f07fd9ee372 100644
--- a/core/modules/rest/tests/modules/rest_test/rest_test.module
+++ b/core/modules/rest/tests/modules/rest_test/rest_test.module
@@ -5,6 +5,11 @@
  * Contains hook implementations for testing REST module.
  */
 
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Access\AccessResult;
+
 /**
  * Implements hook_rest_type_uri_alter().
  */
@@ -22,3 +27,24 @@ function rest_test_rest_relation_uri_alter(&$uri, $context = array()) {
     $uri = 'rest_test_relation';
   }
 }
+
+/**
+ * Implements hook_entity_field_access().
+ *
+ * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::setUp()
+ * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost()
+ */
+function rest_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
+  if ($field_definition->getName() === 'field_rest_test') {
+    switch ($operation) {
+      case 'view':
+        // Never ever allow this field to be viewed: this lets EntityResourceTestBase::testGet() test in a "vanilla" way.
+        return AccessResult::forbidden();
+      case 'edit':
+        return AccessResult::forbidden();
+    }
+  }
+
+  // No opinion.
+  return AccessResult::neutral();
+}
diff --git a/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..b05ddf2a9cb3eaac6083c946752af4f10ad5f2b6
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\Core\Url;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Trait for ResourceTestBase subclasses testing $auth=NULL, i.e. authless/anon.
+ *
+ * Characteristics:
+ * - When no authentication provider is being used, there also cannot be any
+ *   particular error response for missing authentication, since by definition
+ *   there is not any authentication.
+ * - For the same reason, there are no authentication edge cases to test.
+ * - Because no authentication is required, this is vulnerable to CSRF attacks
+ *   by design. Hence a REST resource should probably only allow for anonymous
+ *   for safe (GET/HEAD) HTTP methods, and only with extreme care should unsafe
+ *   (POST/PATCH/DELETE) HTTP methods be allowed for a REST resource that allows
+ *   anonymous access.
+ */
+trait AnonResourceTestTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    throw new \LogicException('When testing for anonymous users, authentication cannot be missing.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {}
+
+}
diff --git a/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..6f8c6217271c8b5ee2f70d722885b2dd014749f2
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\Core\Url;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Trait for ResourceTestBase subclasses testing $auth=basic_auth.
+ *
+ * Characteristics:
+ * - Every request must send an Authorization header.
+ * - When accessing a URI that requires authentication without being
+ *   authenticated, a 401 response must be sent.
+ * - Because every request must send an authorization, there is no danger of
+ *   CSRF attacks.
+ */
+trait BasicAuthResourceTestTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAuthenticationRequestOptions($method) {
+    return [
+      'headers' => [
+        'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {}
+
+}
diff --git a/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..c231ffb6939c2ab37625793b17783a2e722d86f8
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\Core\Url;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Trait for ResourceTestBase subclasses testing $auth=cookie.
+ *
+ * Characteristics:
+ * - After performing a valid "log in" request, the server responds with a 2xx
+ *   status code and a 'Set-Cookie' response header. This cookie is what
+ *   continues to identify the user in subsequent requests.
+ * - When accessing a URI that requires authentication without being
+ *   authenticated, a standard 403 response must be sent.
+ * - Because of the reliance on cookies, and the fact that user agents send
+ *   cookies with every request, this is vulnerable to CSRF attacks. To mitigate
+ *   this, the response for the "log in" request contains a CSRF token that must
+ *   be sent with every unsafe (POST/PATCH/DELETE) HTTP request.
+ */
+trait CookieResourceTestTrait {
+
+  /**
+   * The session cookie.
+   *
+   * @see ::initAuthentication
+   *
+   * @var string
+   */
+  protected $sessionCookie;
+
+  /**
+   * The CSRF token.
+   *
+   * @see ::initAuthentication
+   *
+   * @var string
+   */
+  protected $csrfToken;
+
+  /**
+   * The logout token.
+   *
+   * @see ::initAuthentication
+   *
+   * @var string
+   */
+  protected $logoutToken;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function initAuthentication() {
+    // @todo Remove hardcoded use of the 'json' format, and use static::$format
+    // + static::$mimeType instead in https://www.drupal.org/node/2820888.
+    $user_login_url = Url::fromRoute('user.login.http')
+      ->setRouteParameter('_format', 'json');
+
+    $request_body = [
+      'name' => $this->account->name->value,
+      'pass' => $this->account->passRaw,
+    ];
+
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, 'json');
+    $request_options[RequestOptions::HEADERS]['Accept'] = 'application/json';
+    $response = $this->request('POST', $user_login_url, $request_options);
+
+    // Parse and store the session cookie.
+    $this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0];
+
+    // Parse and store the CSRF token and logout token.
+    $data = $this->serializer->decode($response->getBody()->getContents(), static::$format);
+    $this->csrfToken = $data['csrf_token'];
+    $this->logoutToken = $data['logout_token'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAuthenticationRequestOptions($method) {
+    $request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie;
+    // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+    if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
+      $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
+    }
+    return $request_options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertResourceErrorResponse(403, '', $response);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
+    // X-CSRF-Token request header is unnecessary for safe and side effect-free
+    // HTTP methods. No need for additional assertions.
+    // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+    if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
+      return;
+    }
+
+
+    unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']);
+
+
+    // DX: 403 when missing X-CSRF-Token request header.
+    $response = $this->request($method, $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for';
+
+
+    // DX: 403 when invalid X-CSRF-Token request header.
+    $response = $this->request($method, $url, $request_options);
+    $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c764bda71ab1d9209443264ff372dc70784a903
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class BlockJsonAnonTest extends BlockResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f191e124abfcbc07c7f049651a99662e75609952
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class BlockJsonBasicAuthTest extends BlockResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f609448f6beacfd0dd316cfd1d198cba65b5ab94
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class BlockJsonCookieTest extends BlockResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..94c0b047505cf395513813ad9182a0cce3eeeb7c
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\block\Entity\Block;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+
+abstract class BlockResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block';
+
+  /**
+   * @var \Drupal\block\BlockInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->entity->setVisibilityConfig('user_role', [])->save();
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['administer blocks']);
+        break;
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['administer blocks']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $block = Block::create([
+      'plugin' => 'llama_block',
+      'region' => 'header',
+      'id' => 'llama',
+      'theme' => 'classy',
+    ]);
+    // All blocks can be viewed by the anonymous user by default. An interesting
+    // side effect of this is that any anonymous user is also able to read the
+    // corresponding block config entity via REST, even if an authentication
+    // provider is configured for the block config entity REST resource! In
+    // other words: Block entities do not distinguish between 'view' as in
+    // "render on a page" and 'view' as in "read the configuration".
+    // This prevents that.
+    // @todo Fix this in https://www.drupal.org/node/2820315.
+    $block->setVisibilityConfig('user_role', [
+      'id' => 'user_role',
+      'roles' => ['non-existing-role' => 'non-existing-role'],
+      'negate' => FALSE,
+      'context_mapping' => [
+        'user' => '@user.current_user_context:current_user',
+      ],
+    ]);
+    $block->save();
+
+    return $block;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $normalization = [
+      'uuid' => $this->entity->uuid(),
+      'id' => 'llama',
+      'weight' => NULL,
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [
+        'theme' => [
+          'classy',
+        ],
+      ],
+      'theme' => 'classy',
+      'region' => 'header',
+      'provider' => NULL,
+      'plugin' => 'llama_block',
+      'settings' => [
+        'id' => 'broken',
+        'label' => '',
+        'provider' => 'core',
+        'label_display' => 'visible',
+      ],
+      'visibility' => [],
+    ];
+
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // @see ::createEntity()
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags() {
+    // Because the 'user.permissions' cache context is missing, the cache tag
+    // for the anonymous user role is never added automatically.
+    return array_filter(parent::getExpectedCacheTags(), function ($tag) {
+      return $tag !== 'config:user.role.anonymous';
+    });
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6ce580d03072d94791847f63b8f4dbb8bacb9347
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Comment;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class CommentJsonAnonTest extends CommentResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   *
+   * Anononymous users cannot edit their own comments.
+   *
+   * @see \Drupal\comment\CommentAccessControlHandler::checkAccess
+   *
+   * Therefore we grant them the 'administer comments' permission for the
+   * purpose of this test.
+   *
+   * @see ::setUpAuthorization
+   */
+  protected static $patchProtectedFieldNames = [
+    'pid',
+    'entity_id',
+    'changed',
+    'thread',
+    'entity_type',
+    'field_name',
+  ];
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ab5138478022cbd8ea51e099677f927d3b0915e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Comment;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class CommentJsonBasicAuthTest extends CommentResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..967baa5d650592e33d8737f7293688f0ecc2584e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Comment;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class CommentJsonCookieTest extends CommentResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..c4e45e1c40ecff8f329f294c44a39916abe878ae
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php
@@ -0,0 +1,309 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Comment;
+
+use Drupal\comment\Entity\Comment;
+use Drupal\comment\Entity\CommentType;
+use Drupal\comment\Tests\CommentTestTrait;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+abstract class CommentResourceTestBase extends EntityResourceTestBase {
+
+  use CommentTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['comment', 'entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'comment';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'pid',
+    'entity_id',
+    'uid',
+    'name',
+    'homepage',
+    'created',
+    'changed',
+    'status',
+    'thread',
+    'entity_type',
+    'field_name',
+  ];
+
+  /**
+   * @var \Drupal\comment\CommentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access comments', 'view test entity']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['post comments']);
+        break;
+      case 'PATCH':
+        // Anononymous users are not ever allowed to edit their own comments. To
+        // be able to test PATCHing comments as the anonymous user, the more
+        // permissive 'administer comments' permission must be granted.
+        // @see \Drupal\comment\CommentAccessControlHandler::checkAccess
+        if (static::$auth) {
+          $this->grantPermissionsToTestedRole(['edit own comments']);
+        }
+        else {
+          $this->grantPermissionsToTestedRole(['administer comments']);
+        }
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer comments']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "bar" bundle for the "entity_test" entity type and create.
+    $bundle = 'bar';
+    entity_test_create_bundle($bundle, NULL, 'entity_test');
+
+    // Create a comment field on this bundle.
+    $this->addDefaultCommentField('entity_test', 'bar', 'comment');
+
+    // Create a "Camelids" test entity that the comment will be assigned to.
+    $commented_entity = EntityTest::create(array(
+      'name' => 'Camelids',
+      'type' => 'bar',
+    ));
+    $commented_entity->save();
+
+    // Create a "Llama" comment.
+    $comment = Comment::create([
+      'comment_body' => [
+        'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+        'format' => 'plain_text',
+      ],
+      'entity_id' => $commented_entity->id(),
+      'entity_type' => 'entity_test',
+      'field_name' => 'comment',
+    ]);
+    $comment->setSubject('Llama')
+      ->setOwnerId(static::$auth ? $this->account->id() : 0)
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789);
+    $comment->save();
+
+    return $comment;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'cid' => [
+        ['value' => 1],
+      ],
+      'uuid' => [
+        ['value' => $this->entity->uuid()],
+      ],
+      'langcode' => [
+        [
+          'value' => 'en',
+        ],
+      ],
+      'comment_type' => [
+        [
+          'target_id' => 'comment',
+          'target_type' => 'comment_type',
+          'target_uuid' => CommentType::load('comment')->uuid(),
+        ],
+      ],
+      'subject' => [
+        [
+          'value' => 'Llama',
+        ],
+      ],
+      'status' => [
+        [
+          'value' => 1,
+        ],
+      ],
+      'created' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'changed' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'default_langcode' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+      'uid' => [
+        [
+          'target_id' => $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/' . $author->id(),
+        ],
+      ],
+      'pid' => [],
+      'entity_type' => [
+        [
+          'value' => 'entity_test',
+        ],
+      ],
+      'entity_id' => [
+        [
+          'target_id' => '1',
+          'target_type' => 'entity_test',
+          'target_uuid' => EntityTest::load(1)->uuid(),
+          'url' => base_path() . 'entity_test/1',
+        ],
+      ],
+      'field_name' => [
+        [
+          'value' => 'comment',
+        ],
+      ],
+      'name' => [],
+      'homepage' => [],
+      'thread' => [
+        [
+          'value' => '01/',
+        ],
+      ],
+      'comment_body' => [
+        [
+          'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+          'format' => 'plain_text',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'comment_type' => [
+        [
+          'target_id' => 'comment',
+        ],
+      ],
+      'entity_type' => [
+        [
+          'value' => 'entity_test',
+        ],
+      ],
+      'entity_id' => [
+        [
+          'target_id' => EntityTest::load(1)->id(),
+        ],
+      ],
+      'field_name' => [
+        [
+          'value' => 'comment',
+        ],
+      ],
+      'subject' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+      'comment_body' => [
+        [
+          'value' => 'Llamas are awesome.',
+          'format' => 'plain_text',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPatchEntity() {
+    return array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE, 'entity_id' => TRUE, 'field_name' => TRUE]);
+  }
+
+  /**
+   * Tests POSTing a comment without critical base fields.
+   *
+   * testPost() is testing with the most minimal normalization possible: the one
+   * returned by ::getNormalizedPostEntity().
+   *
+   * But Comment entities have some very special edge cases:
+   * - base fields that are not marked as required in
+   *   \Drupal\comment\Entity\Comment::baseFieldDefinitions() yet in fact are
+   *   required.
+   * - base fields that are marked as required, but yet can still result in
+   *   validation errors other than "missing required field".
+   */
+  public function testPostDxWithoutCriticalBaseFields() {
+    $this->initAuthentication();
+    $this->provisionEntityResource();
+    $this->setUpAuthorization('POST');
+
+    $url = $this->getPostUrl()->setOption('query', ['_format' => static::$format]);
+    $request_options = [];
+    $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+    // DX: 422 when missing 'entity_type' field.
+    $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE]), static::$format);
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'A fatal error occurred: Internal Server Error', $response);
+    //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response);
+
+    // DX: 422 when missing 'entity_id' field.
+    $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_id' => TRUE]), static::$format);
+    // @todo Remove the try/catch in favor of the two commented lines in
+    // https://www.drupal.org/node/2820364.
+    try {
+      $response = $this->request('POST', $url, $request_options);
+      // This happens on DrupalCI.
+      $this->assertSame(500, $response->getStatusCode());
+    }
+    catch (\Exception $e) {
+      // This happens on Wim's local machine.
+      $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
+    }
+    //$response = $this->request('POST', $url, $request_options);
+    //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response);
+
+    // DX: 422 when missing 'entity_type' field.
+    $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['field_name' => TRUE]), static::$format);
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'A fatal error occurred: Field  is unknown.', $response);
+    //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nfield_name: This value should not be null.\n", $response);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..db79e6c090f5edabffd052945814c441bb3d9596
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class ConfigTestJsonAnonTest extends ConfigTestResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..309a8f12f640bdad88bae03c4fea7ff750d30130
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class ConfigTestJsonBasicAuthTest extends ConfigTestResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c243bde996603de536d736da05fdc838cdcf55a6
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class ConfigTestJsonCookieTest extends ConfigTestResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..9fe073b097b00c0e8340a88015c9737a7824e501
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+
+abstract class ConfigTestResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['config_test', 'config_test_rest'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'config_test';
+
+  /**
+   * @var \Drupal\config_test\ConfigTestInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['view config_test']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $config_test = ConfigTest::create([
+      'id' => 'llama',
+      'label' => 'Llama',
+    ]);
+    $config_test->save();
+
+    return $config_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $normalization = [
+      'uuid' => $this->entity->uuid(),
+      'id' => 'llama',
+      'weight' => 0,
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [],
+      'label' => 'Llama',
+      'style' => NULL,
+      'size' => NULL,
+      'size_value' => NULL,
+      'protected_property' => NULL,
+    ];
+
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..d944c61afe76b8e802c3dbbe362719c8fc0393ae
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,1088 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\rest\Functional\ResourceTestBase;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Even though there is the generic EntityResource, it's necessary for every
+ * entity type to have its own test, because they each have different fields,
+ * validation constraints, et cetera. It's not because the generic case works,
+ * that every case works.
+ *
+ * Furthermore, it's necessary to test every format separately, because there
+ * can be entity type-specific normalization or serialization problems.
+ *
+ * Subclass this for every entity type. Also respect instructions in
+ * \Drupal\rest\Tests\ResourceTestBase.
+ *
+ * For example, for the node test coverage, there is the (abstract)
+ * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which
+ * is then again subclassed for every authentication provider:
+ * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest
+ * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
+ * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest
+ * But the HAL module also adds a new format ('hal_json'), so that format also
+ * needs test coverage (for its own peculiarities in normalization & encoding):
+ * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest
+ * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest
+ * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest
+ *
+ * In other words: for every entity type there should be:
+ * 1. an abstract subclass that includes the entity type-specific authorization
+ *    (permissions or perhaps custom access control handling, such as node
+ *    grants), plus
+ * 2. a concrete subclass extending the abstract entity type-specific subclass
+ *    that specifies the exact @code $format @endcode, @code $mimeType @endcode,
+ *    @code $expectedErrorMimeType @endcode and @code $auth @endcode for this
+ *    concrete test. Usually that's all that's necessary: most concrete
+ *    subclasses will be very thin.
+ *
+ * For every of these concrete subclasses, a comprehensive test scenario will
+ * run per HTTP method:
+ * - ::testGet()
+ * - ::testPost()
+ * - ::testPatch()
+ * - ::testDelete()
+ *
+ * If there is an entity type-specific edge case scenario to test, then add that
+ * to the entity type-specific abstract subclass. Example:
+ * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields
+ *
+ * If there is an entity type-specific format-specific edge case to test, then
+ * add that to a concrete subclass. Example:
+ * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames
+ */
+abstract class EntityResourceTestBase extends ResourceTestBase {
+
+  /**
+   * The tested entity type.
+   *
+   * @var string
+   */
+  protected static $entityTypeId = NULL;
+
+  /**
+   * The fields that are protected against modification during PATCH requests.
+   *
+   * @var string[]
+   */
+  protected static $patchProtectedFieldNames;
+
+  /**
+   * Optionally specify which field is the 'label' field. Some entities specify
+   * a 'label_callback', but not a 'label' entity key. For example: User.
+   *
+   * @see ::getInvalidNormalizedEntityToCreate
+   *
+   * @var string|null
+   */
+  protected static $labelFieldName = NULL;
+
+  /**
+   * The entity ID for the first created entity in testPost().
+   *
+   * The default value of 2 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $firstCreatedEntityId = 2;
+
+  /**
+   * The entity ID for the second created entity in testPost().
+   *
+   * The default value of 3 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $secondCreatedEntityId = 3;
+
+  /**
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $httpClient;
+
+  /**
+   * The main entity used for testing.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $entityStorage;
+
+  /**
+   * Modules to install.
+   *
+   * @var array
+   */
+  public static $modules = ['rest_test', 'text'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function provisionEntityResource() {
+    // It's possible to not have any authentication providers enabled, when
+    // testing public (anonymous) usage of a REST resource.
+    $auth = isset(static::$auth) ? [static::$auth] : [];
+    $this->provisionResource('entity.' . static::$entityTypeId, [static::$format], $auth);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('serializer');
+    $this->entityStorage = $this->container->get('entity_type.manager')
+      ->getStorage(static::$entityTypeId);
+
+    // Set up a HTTP client that accepts relative URLs.
+    $this->httpClient = $this->container->get('http_client_factory')
+      ->fromOptions(['base_uri' => $this->baseUrl]);
+
+    // Create an entity.
+    $this->entity = $this->createEntity();
+
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Add access-protected field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'type' => 'text',
+      ])
+        ->setCardinality(1)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      // Reload entity so that it has the new field.
+      $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
+
+      // Set a default value on the field.
+      $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+      // @todo Remove in this if-test in https://www.drupal.org/node/2808335.
+      if ($this->entity instanceof EntityChangedInterface) {
+        $changed = $this->entity->getChangedTime();
+        $this->entity->setChangedTime(42);
+        $this->entity->save();
+        $this->entity->setChangedTime($changed);
+      }
+      $this->entity->save();
+    }
+
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+  }
+
+  /**
+   * Creates the entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity to be tested.
+   */
+  abstract protected function createEntity();
+
+  /**
+   * Returns the expected normalization of the entity.
+   *
+   * @see ::createEntity()
+   *
+   * @return array
+   */
+  abstract protected function getExpectedNormalizedEntity();
+
+  /**
+   * Returns the normalized POST entity.
+   *
+   * @see ::testPost
+   *
+   * @return array
+   */
+  abstract protected function getNormalizedPostEntity();
+
+  /**
+   * Returns the normalized PATCH entity.
+   *
+   * By default, reuses ::getNormalizedPostEntity(), which works fine for most
+   * entity types. A counterexample: the 'comment' entity type.
+   *
+   * @see ::testPatch
+   *
+   * @return array
+   */
+  protected function getNormalizedPatchEntity() {
+    return $this->getNormalizedPostEntity();
+  }
+
+  /**
+   * The expected cache tags for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheTags() {
+    $expected_cache_tags = [
+      'config:rest.resource.entity.' . static::$entityTypeId,
+    ];
+    if (!static::$auth) {
+      $expected_cache_tags[] = 'config:user.role.anonymous';
+    }
+    return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
+  }
+
+  /**
+   * The expected cache contexts for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheContexts() {
+    return [
+      'user.permissions',
+    ];
+  }
+
+  /**
+   * Test a GET request for an entity, plus edge cases to ensure good DX.
+   */
+  public function testGet() {
+    $this->initAuthentication();
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = $this->getUrl();
+    $request_options = [];
+
+
+    // DX: 404 when resource not provisioned, 403 if canonical route. HTML
+    // response because missing ?_format query string.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
+    // response because ?_format query string is present.
+    $response = $this->request('GET', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertResourceErrorResponse(403, '', $response);
+    }
+    else {
+      $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+    }
+
+
+    $this->provisionEntityResource();
+    // Simulate the developer again forgetting the ?_format query string.
+    $url->setOption('query', []);
+
+
+
+    // DX: 406 when ?_format is missing, except when requesting a canonical HTML
+    // route.
+    $response = $this->request('GET', $url, $request_options);
+    if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
+      $this->assertSame(403, $response->getStatusCode());
+    }
+    else {
+      $this->assert406Response($response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: forgetting authentication: authentication provider-specific error
+    // response.
+    if (static::$auth) {
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertResponseWhenMissingAuthentication($response);
+    }
+
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->setUpAuthorization('GET');
+
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    $this->assertResourceResponse(200, '', $response);
+    if (!$this->account) {
+      $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache'));
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    }
+    $head_headers = $response->getHeaders();
+
+    // 200 for well-formed GET request. Page Cache hit because of HEAD request.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    if (!static::$auth) {
+      $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache'));
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    }
+    $cache_tags_header_value = $response->getHeader('X-Drupal-Cache-Tags')[0];
+    $this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value));
+    $cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0];
+    $this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value));
+    // Comparing the exact serialization is pointless, because the order of
+    // fields does not matter (at least not yet). That's why we only compare the
+    // normalized entity with the decoded response: it's comparing PHP arrays
+    // instead of strings.
+    $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string) $response->getBody(), static::$format));
+    // Not only assert the normalization, also assert deserialization of the
+    // response results in the expected object.
+    $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
+    $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+    $get_headers = $response->getHeaders();
+
+    // Verify that the GET and HEAD responses are the same. The only difference
+    // is that there's no body.
+    $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache'];
+    foreach ($ignored_headers as $ignored_header) {
+      unset($head_headers[$ignored_header]);
+      unset($get_headers[$ignored_header]);
+    }
+    $this->assertSame($get_headers, $head_headers);
+
+
+    $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
+
+
+    // 200 for well-formed request.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+
+    $url->setOption('query', ['_format' => 'non_existing_format']);
+
+
+    // DX: 406 when requesting unsupported format.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assert406Response($response);
+    $this->assertNotSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+    $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+
+
+    // DX: 406 when requesting unsupported format but specifying Accept header.
+    // @todo Update in https://www.drupal.org/node/2825347.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assert406Response($response);
+    $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+    $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format);
+    $url->setRouteParameter(static::$entityTypeId, 987654321);
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 404 when GETting non-existing entity.
+    $response = $this->request('GET', $url, $request_options);
+    $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET.' . static::$format . '")';
+    $this->assertResourceErrorResponse(404, $message, $response);
+  }
+
+  /**
+   * Tests a POST request for an entity, plus edge cases to ensure good DX.
+   */
+  public function testPost() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
+      return;
+    }
+
+    $this->initAuthentication();
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format);
+    // @todo Change to ['uuid' => UUID] in https://www.drupal.org/node/2820743.
+    $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [['value' => $this->randomMachineName(129)]]], static::$format);
+    $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = $this->getPostUrl();
+    $request_options = [];
+
+
+    // DX: 404 when resource not provisioned, but HTML if canonical route.
+    $response = $this->request('POST', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertSame(404, $response->getStatusCode());
+      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    }
+    else {
+      $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 404 when resource not provisioned.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getPostUrl()->setAbsolute()->toString()) . '"', $response);
+
+
+    $this->provisionEntityResource();
+    // Simulate the developer again forgetting the ?_format query string.
+    $url->setOption('query', []);
+
+
+    // DX: 415 when no Content-Type request header, but HTML if canonical route.
+    $response = $this->request('POST', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertSame(415, $response->getStatusCode());
+      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+      $this->assertContains(htmlspecialchars('No "Content-Type" request header specified'), $response->getBody()->getContents());
+    }
+    else {
+      $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+    // DX: 400 when no request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+    // $this->assertResourceErrorResponse(400, 'Syntax error', $response);
+    $this->assertSame(400, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody());
+
+
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+
+    if (static::$auth) {
+      // DX: forgetting authentication: authentication provider-specific error
+      // response.
+      $response = $this->request('POST', $url, $request_options);
+      $this->assertResponseWhenMissingAuthentication($response);
+    }
+
+
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->setUpAuthorization('POST');
+
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('POST', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = ucfirst($label_field);
+    // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+    // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: <em class=\"placeholder\">Title</em>: this field cannot hold more than 1 values.\n", $response);
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: <em class=\"placeholder\">$label_field_capitalized</em>: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody());
+
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+
+    // DX: 422 when invalid entity: UUID field too long.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+    // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: <em class=\"placeholder\">UUID</em>: may not be longer than 128 characters.\n", $response);
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nuuid.0.value: <em class=\"placeholder\">UUID</em>: may not be longer than 128 characters.\n"], static::$format), (string) $response->getBody());
+
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+
+
+    // DX: 403 when entity contains field without 'edit' access.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Add trailing period in https://www.drupal.org/node/2821013.
+    $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'", $response);
+
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+
+    // Before sending a well-formed request, allow the normalization and
+    // authentication provider edge cases to also be tested.
+    $this->assertNormalizationEdgeCases('POST', $url, $request_options);
+    $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Update this in https://www.drupal.org/node/2826407. Also move it
+    // higher, before the "no request body" test. That's impossible right now,
+    // because the format validation happens too late.
+    $this->assertResourceErrorResponse(415, '', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertSame([str_replace($this->entity->id(), static::$firstCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+
+    $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
+
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertSame([str_replace($this->entity->id(), static::$secondCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+  }
+
+  /**
+   * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
+   */
+  public function testPatch() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
+      return;
+    }
+
+    $this->initAuthentication();
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
+    $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = $this->getUrl();
+    $request_options = [];
+
+
+    // DX: 405 when resource not provisioned, but HTML if canonical route.
+    $response = $this->request('PATCH', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertSame(405, $response->getStatusCode());
+      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    }
+    else {
+      $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 405 when resource not provisioned.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
+
+
+    $this->provisionEntityResource();
+    // Simulate the developer again forgetting the ?_format query string.
+    $url->setOption('query', []);
+
+
+    // DX: 415 when no Content-Type request header, but HTML if canonical route.
+    $response = $this->request('PATCH', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertSame(415, $response->getStatusCode());
+      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+      $this->assertTrue(FALSE !== strpos($response->getBody()->getContents(), htmlspecialchars('No "Content-Type" request header specified')));
+    }
+    else {
+      $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+    // DX: 400 when no request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+    // $this->assertResourceErrorResponse(400, 'Syntax error', $response);
+    $this->assertSame(400, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody());
+
+
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+
+    if (static::$auth) {
+      // DX: forgetting authentication: authentication provider-specific error
+      // response.
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResponseWhenMissingAuthentication($response);
+    }
+
+
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->setUpAuthorization('PATCH');
+
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('PATCH', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = ucfirst($label_field);
+    // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+    // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: <em class=\"placeholder\">Title</em>: this field cannot hold more than 1 values.\n", $response);
+    // $this->assertSame(422, $response->getStatusCode());
+    // $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: <em class=\"placeholder\">$label_field_capitalized</em>: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody());
+
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+
+    // DX: 403 when entity contains field without 'edit' access.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
+
+
+    // DX: 403 when sending PATCH request with read-only fields.
+    // First send all fields (the "maximum normalization"). Assert the expected
+    // error message for the first PATCH-protected field. Remove that field from
+    // the normalization, send another request, assert the next PATCH-protected
+    // field error message. And so on.
+    $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
+    for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
+      $max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i));
+      $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
+      $response = $this->request('PATCH', $url, $request_options);
+      $this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response);
+    }
+
+    // 200 for well-formed request that sends the maximum number of fields.
+    $max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames);
+    $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+
+    // Before sending a well-formed request, allow the normalization and
+    // authentication provider edge cases to also be tested.
+    $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
+    $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Update this in https://www.drupal.org/node/2826407. Also move it
+    // higher, before the "no request body" test. That's impossible right now,
+    // because the format validation happens too late.
+    $this->assertResourceErrorResponse(415, '', $response);
+
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // Ensure that fields do not get deleted if they're not present in the PATCH
+    // request. Test this using the configurable field that we added, but which
+    // is not sent in the PATCH request.
+    $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test')->value);
+
+
+    $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
+
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+  }
+
+  /**
+   * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
+   */
+  public function testDelete() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
+      return;
+    }
+
+    $this->initAuthentication();
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = $this->getUrl();
+    $request_options = [];
+
+
+    // DX: 405 when resource not provisioned, but HTML if canonical route.
+    $response = $this->request('DELETE', $url, $request_options);
+    if ($has_canonical_url) {
+      $this->assertSame(405, $response->getStatusCode());
+      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    }
+    else {
+      $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+    }
+
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+
+    // DX: 405 when resource not provisioned.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
+
+
+    $this->provisionEntityResource();
+
+
+    if (static::$auth) {
+      // DX: forgetting authentication: authentication provider-specific error
+      // response.
+      $response = $this->request('DELETE', $url, $request_options);
+      $this->assertResponseWhenMissingAuthentication($response);
+    }
+
+
+    $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('DELETE', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->setUpAuthorization('DELETE');
+
+
+    // Before sending a well-formed request, allow the authentication provider's
+    // edge cases to also be tested.
+    $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
+
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertSame(204, $response->getStatusCode());
+    // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed.
+    // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+    $this->assertSame('', $response->getBody()->getContents());
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+
+    $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+    $this->entity = $this->createEntity();
+    $url = $this->getUrl()->setOption('query', $url->getOption('query'));
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('DELETE', $url, $request_options);
+    // @todo Update the message in https://www.drupal.org/node/2808233.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
+
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    $this->assertSame(204, $response->getStatusCode());
+    // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed.
+    // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+    $this->assertSame('', $response->getBody()->getContents());
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
+    // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
+    // types with bundles MUST send their bundle field to be denormalizable.
+    $entity_type = $this->entity->getEntityType();
+    if ($entity_type->hasKey('bundle')) {
+      $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
+      $normalization = $this->getNormalizedPostEntity();
+
+      // The bundle type itself can be validated only if there's a bundle entity
+      // type.
+      if ($entity_type->getBundleEntityType()) {
+        $normalization[$bundle_field_name] = 'bad_bundle_name';
+        $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+        // DX: 400 when incorrect entity type bundle is specified.
+        $response = $this->request($method, $url, $request_options);
+        // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+        //      $this->assertResourceErrorResponse(400, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
+        $this->assertSame(400, $response->getStatusCode());
+        $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+        $this->assertSame($this->serializer->encode(['error' => '"bad_bundle_name" is not a valid bundle type for denormalization.'], static::$format), (string) $response->getBody());
+      }
+
+
+      unset($normalization[$bundle_field_name]);
+      $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+      // DX: 400 when no entity type bundle is specified.
+      $response = $this->request($method, $url, $request_options);
+      // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+      // $this->assertResourceErrorResponse(400, 'A string must be provided as a bundle value.', $response);
+      $this->assertSame(400, $response->getStatusCode());
+      $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+      $this->assertSame($this->serializer->encode(['error' => 'A string must be provided as a bundle value.'], static::$format), (string) $response->getBody());
+    }
+  }
+
+  /**
+   * Gets an entity resource's GET/PATCH/DELETE URL.
+   *
+   * @return \Drupal\Core\Url
+   *   The URL to GET/PATCH/DELETE.
+   */
+  protected function getUrl() {
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+    return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
+  }
+
+  /**
+   * Gets an entity resource's POST URL.
+   *
+   * @return \Drupal\Core\Url
+   *   The URL to POST to.
+   */
+  protected function getPostUrl() {
+    $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create');
+    return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId);
+  }
+
+  /**
+   * Makes the given entity normalization invalid.
+   *
+   * @param array $normalization
+   *   An entity normalization.
+   *
+   * @return array
+   *   The updated entity normalization, now invalid.
+   */
+  protected function makeNormalizationInvalid(array $normalization) {
+    // Add a second label to this entity to make it invalid.
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $normalization[$label_field][1]['value'] = 'Second Title';
+
+    return $normalization;
+  }
+
+  /**
+   * Removes fields from a normalization.
+   *
+   * @param array $normalization
+   *   An entity normalization.
+   * @param string[] $field_names
+   *   The field names to remove from the entity normalization.
+   *
+   * @return array
+   *   The updated entity normalization.
+   *
+   * @see ::testPatch
+   */
+  protected function removeFieldsFromNormalization(array $normalization, $field_names) {
+    return array_diff_key($normalization, array_flip($field_names));
+  }
+
+  /**
+   * Asserts a 406 response… or in some cases a 403 response, because weirdness.
+   *
+   * Asserting a 406 response should be easy, but it's not, due to bugs.
+   *
+   * Drupal returns a 403 response instead of a 406 response when:
+   * - there is a canonical route, i.e. one that serves HTML
+   * - unless the user is logged in with any non-global authentication provider,
+   *   because then they tried to access a route that requires the user to be
+   *   authenticated, but they used an authentication provider that is only
+   *   accepted for specific routes, and HTML routes never have such specific
+   *   authentication providers specified. (By default, only 'cookie' is a
+   *   global authentication provider.)
+   *
+   * @todo Remove this in https://www.drupal.org/node/2805279.
+   *
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response to assert.
+   */
+  protected function assert406Response(ResponseInterface $response) {
+    if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
+      $this->assertSame(403, $response->getStatusCode());
+    }
+    else {
+      // This is the desired response.
+      $this->assertSame(406, $response->getStatusCode());
+    }
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a7e442055dee74f88608e09675ac49f610444823
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class EntityTestJsonAnonTest extends EntityTestResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..be75784104d270ae680a5bef0c41f3478046650b
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class EntityTestJsonBasicAuthTest extends EntityTestResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..84079220dce94364c51637b6800afcf433ee26bc
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class EntityTestJsonCookieTest extends EntityTestResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..da86d0006068b1982b19d3f1d0d4de8b1557000a
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest;
+
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+
+abstract class EntityTestResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * @var \Drupal\entity_test\Entity\EntityTest
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view test entity']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities']);
+        break;
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer entity_test content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity_test = EntityTest::create([
+      'name' => 'Llama',
+      'type' => 'entity_test',
+    ]);
+    $entity_test->setOwnerId(0);
+    $entity_test->save();
+
+    return $entity_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load(0);
+    $normalization = [
+      'uuid' => [
+        [
+          'value' => $this->entity->uuid()
+        ]
+      ],
+      'id' => [
+        [
+          'value' => '1',
+        ],
+      ],
+      'langcode' => [
+        [
+          'value' => 'en',
+        ],
+      ],
+      'type' => [
+        [
+          'value' => 'entity_test',
+        ]
+      ],
+      'name' => [
+        [
+          'value' => 'Llama',
+        ]
+      ],
+      'created' => [
+        [
+          'value' => $this->entity->get('created')->value,
+        ]
+      ],
+      'user_id' => [
+        [
+          'target_id' => $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => $author->toUrl()->toString(),
+        ]
+      ],
+      'field_test_text' => [],
+    ];
+
+    return $normalization;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'type' => 'entity_test',
+      'name' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..24d47c495838a3265a5e16d5ee670a990d6a7693
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class NodeJsonAnonTest extends NodeResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b71d1d57a6e4604e693df4fb73b45ec1a05c5f37
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class NodeJsonBasicAuthTest extends NodeResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f0bd6534b65bef3d8ec457bf477cf227de1b3a0f
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class NodeJsonCookieTest extends NodeResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..d7651c4b9094c70a8c00b2d25a97df42da625aa3
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+
+abstract class NodeResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'node';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'uid',
+    'created',
+    'changed',
+    'promote',
+    'sticky',
+    'revision_timestamp',
+    'revision_uid',
+  ];
+
+  /**
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+        break;
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!NodeType::load('camelids')) {
+      // Create a "Camelids" node type.
+      NodeType::create([
+        'name' => 'Camelids',
+        'type' => 'camelids',
+      ])->save();
+    }
+
+    // Create a "Llama" node.
+    $node = Node::create(['type' => 'camelids']);
+    $node->setTitle('Llama')
+      ->setOwnerId(static::$auth ? $this->account->id() : 0)
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789)
+      ->setRevisionCreationTime(123456789)
+      ->save();
+
+    return $node;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'nid' => [
+        ['value' => 1],
+      ],
+      'uuid' => [
+        ['value' => $this->entity->uuid()],
+      ],
+      'vid' => [
+        ['value' => 1],
+      ],
+      'langcode' => [
+        [
+          'value' => 'en',
+        ],
+      ],
+      'type' => [
+        [
+          'target_id' => 'camelids',
+          'target_type' => 'node_type',
+          'target_uuid' => NodeType::load('camelids')->uuid(),
+        ],
+      ],
+      'title' => [
+        [
+          'value' => 'Llama',
+        ],
+      ],
+      'status' => [
+        [
+          'value' => 1,
+        ],
+      ],
+      'created' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'changed' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'promote' => [
+        [
+          'value' => 1,
+        ],
+      ],
+      'sticky' => [
+        [
+          'value' => '0',
+        ],
+      ],
+      'revision_timestamp' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'revision_translation_affected' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+      'default_langcode' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+      'uid' => [
+        [
+          'target_id' => $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/' . $author->id(),
+        ],
+      ],
+      'revision_uid' => [
+        [
+          'target_id' => $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/' . $author->id(),
+        ],
+      ],
+      'revision_log' => [
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'type' => [
+        [
+          'target_id' => 'camelids',
+        ],
+      ],
+      'title' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3ec96eb7a56f1506eff12a48953fbb9ef20a4cf5
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class RoleJsonAnonTest extends RoleResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..75fcd080028dc55ede68de65c9620a6d6a8a37f9
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * @group rest
+ */
+class RoleJsonBasicAuthTest extends RoleResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertSame(401, $response->getStatusCode());
+    $this->assertSame('{"message":"A fatal error occurred: No authentication credentials provided."}', (string) $response->getBody());
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f4ec073c22e490580e86e26322510a4927cab1d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class RoleJsonCookieTest extends RoleResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee719c4b5696b258ece5e4a2f09cccb960dc1013
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\Role;
+
+abstract class RoleResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user_role';
+
+  /**
+   * @var \Drupal\user\RoleInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer permissions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $role = Role::create([
+      'id' => 'llama',
+      'name' => $this->randomString(),
+    ]);
+    $role->save();
+
+    return $role;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return [
+      'uuid' => $this->entity->uuid(),
+      'weight' => 2,
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [],
+      'id' => 'llama',
+      'label' => NULL,
+      'is_admin' => NULL,
+      'permissions' => [],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6e01c0314936912c14930030882e85b480fe4b9a
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class TermJsonAnonTest extends TermResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6befa3a9ad3c51a5dcb57c95024028b13b3449b3
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class TermJsonBasicAuthTest extends TermResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6268a747ff7b20d7e73137e25ae36694b4e17bb3
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class TermJsonCookieTest extends TermResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6dce4f4b2de375e9e3c52ef6c1eda122590117e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+
+abstract class TermResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_term';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed',
+  ];
+
+  /**
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        // @todo Update once https://www.drupal.org/node/2824408 lands.
+        $this->grantPermissionsToTestedRole(['administer taxonomy']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::load('camelids');
+    if (!$vocabulary) {
+      // Create a "Camelids" vocabulary.
+      $vocabulary = Vocabulary::create([
+        'name' => 'Camelids',
+        'vid' => 'camelids',
+      ]);
+      $vocabulary->save();
+    }
+
+    // Create a "Llama" taxonomy term.
+    $term = Term::create(['vid' => $vocabulary->id()])
+      ->setName('Llama')
+      ->setChangedTime(123456789);
+    $term->save();
+
+    return $term;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return [
+      'tid' => [
+        ['value' => 1],
+      ],
+      'uuid' => [
+        ['value' => $this->entity->uuid()],
+      ],
+      'vid' => [
+        [
+          'target_id' => 'camelids',
+          'target_type' => 'taxonomy_vocabulary',
+          'target_uuid' => Vocabulary::load('camelids')->uuid(),
+        ],
+      ],
+      'name' => [
+        ['value' => 'Llama'],
+      ],
+      'description' => [
+        [
+          'value' => NULL,
+          'format' => NULL,
+        ],
+      ],
+      'parent' => [],
+      'weight' => [
+        ['value' => 0],
+      ],
+      'langcode' => [
+        [
+          'value' => 'en',
+        ],
+      ],
+      'changed' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'default_langcode' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'vid' => [
+        [
+          'target_id' => 'camelids',
+        ],
+      ],
+      'name' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a20aff8346947cfc22be8cd497644e7b1f79a65d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\User;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class UserJsonAnonTest extends UserResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f67e0a6790edb4248d159afabeef8d00a829de3e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\User;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class UserJsonBasicAuthTest extends UserResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..12ee0c919c2c0c9757d9420d39f9a13cbbd28489
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\User;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class UserJsonCookieTest extends UserResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..31cf083af5c9aeec0a0c48c9b7a2a03d19eed1db
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
@@ -0,0 +1,232 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\User;
+
+use Drupal\Core\Url;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+abstract class UserResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed',
+  ];
+
+  /**
+   * @var \Drupal\user\UserInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $labelFieldName = 'name';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $firstCreatedEntityId = 4;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $secondCreatedEntityId = 5;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access user profiles']);
+        break;
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer users']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Llama" user.
+    $user = User::create(['created' => 123456789]);
+    $user->setUsername('Llama')
+      ->setChangedTime(123456789)
+      ->activate()
+      ->save();
+
+    return $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return [
+      'uid' => [
+        ['value' => '3'],
+      ],
+      'uuid' => [
+        ['value' => $this->entity->uuid()],
+      ],
+      'langcode' => [
+        [
+          'value' => 'en',
+        ],
+      ],
+      'name' => [
+        [
+          'value' => 'Llama',
+        ],
+      ],
+      'created' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'changed' => [
+        [
+          'value' => '123456789',
+        ],
+      ],
+      'default_langcode' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'name' => [
+        [
+          'value' => 'Dramallama ' . $this->randomMachineName(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields of the logged in account.
+   */
+  public function testPatchDxForSecuritySensitiveBaseFields() {
+    // The anonymous user is never allowed to modify itself.
+    if (!static::$auth) {
+      $this->markTestSkipped();
+    }
+
+    $this->initAuthentication();
+    $this->provisionEntityResource();
+    $this->setUpAuthorization('PATCH');
+
+    /** @var \Drupal\user\UserInterface $user */
+    $user = static::$auth ? $this->account : User::load(0);
+    $original_normalization = array_diff_key($this->serializer->normalize($user, static::$format), ['changed' => TRUE]);
+
+
+    // Since this test must be performed by the user that is being modified,
+    // we cannot use $this->getUrl().
+    $url = $user->toUrl()->setOption('query', ['_format' => static::$format]);
+    $request_options = [
+      RequestOptions::HEADERS => ['Content-Type' => static::$mimeType],
+    ];
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+    // Test case 1: changing email.
+    $normalization = $original_normalization;
+    $normalization['mail'] = [['value' => 'new-email@example.com']];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+    // DX: 422 when changing email without providing the password.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+    // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n", $response);
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n"], static::$format), (string) $response->getBody());
+
+
+    $normalization['pass'] = [['existing' => 'wrong']];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+    // DX: 422 when changing email while providing a wrong password.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+    // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n", $response);
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n"], static::$format), (string) $response->getBody());
+
+
+    $normalization['pass'] = [['existing' => $this->account->passRaw]];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+
+    // Test case 2: changing password.
+    $normalization = $original_normalization;
+    $new_password = $this->randomString();
+    $normalization['pass'] = [['value' => $new_password]];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+    // DX: 422 when changing password without providing the current password.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+    // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Password</em>.\n", $response);
+    $this->assertSame(422, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Password</em>.\n"], static::$format), (string) $response->getBody());
+
+
+    $normalization['pass'][0]['existing'] = $this->account->pass_raw;
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+
+
+    // Verify that we can log in with the new password.
+    $request_body = [
+      'name' => $user->getAccountName(),
+      'pass' => $new_password,
+    ];
+    $request_options = [
+      RequestOptions::HEADERS => [],
+      RequestOptions::BODY => $this->serializer->encode($request_body, 'json'),
+    ];
+    $response = $this->httpClient->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json')->toString(), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4e29106996f165cf8b64d9e3bdd0cedfc6e478c7
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class VocabularyJsonAnonTest extends VocabularyResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * Disable the GET test coverage due to bug in taxonomy module.
+   * @todo Fix in https://www.drupal.org/node/2805281: remove this override.
+   */
+  public function testGet() {
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..106005006eb21e95ca67e150655d5ecfdb312c2f
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class VocabularyJsonBasicAuthTest extends VocabularyResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use JsonBasicAuthWorkaroundFor2805281Trait {
+    JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f8939c68bfa16b1eee600631846b81f7ac0f8da8
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * @group rest
+ */
+class VocabularyJsonCookieTest extends VocabularyResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..abada74b35fdc47333f59dad852e21a0d3e16257
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+
+abstract class VocabularyResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_vocabulary';
+
+  /**
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer taxonomy']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::create([
+      'name' => 'Llama',
+      'vid' => 'llama',
+    ]);
+    $vocabulary->save();
+
+    return $vocabulary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return [
+      'uuid' => $this->entity->uuid(),
+      'vid' => 'llama',
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [],
+      'name' => 'Llama',
+      'description' => NULL,
+      'hierarchy' => 0,
+      'weight' => 0,
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php
new file mode 100644
index 0000000000000000000000000000000000000000..495bf5ae745c4c708fc5d8705cb9636d50110511
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Psr\Http\Message\ResponseInterface;
+
+trait JsonBasicAuthWorkaroundFor2805281Trait {
+
+  /**
+   * {@inheritdoc}
+   *
+   * Note that strange 'A fatal error occurred: ' prefix, that should not exist.
+   *
+   * @todo Fix in https://www.drupal.org/node/2805281: remove this trait.
+   */
+  protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertSame(401, $response->getStatusCode());
+    $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+    // Note that strange 'A fatal error occurred: ' prefix, that should not
+    // exist.
+    // @todo Fix in https://www.drupal.org/node/2805281.
+    $this->assertSame('{"message":"A fatal error occurred: No authentication credentials provided."}', (string) $response->getBody());
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f1f0458dca6cc51b722f9f03efb04081824d8707
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,349 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\Core\Url;
+use Drupal\rest\RestResourceConfigInterface;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Subclass this for every REST resource, every format and every auth provider.
+ *
+ * For more guidance see
+ * \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase
+ * which has recommendations for testing the
+ * \Drupal\rest\Plugin\rest\resource\EntityResource REST resource for every
+ * format and every auth provider. It's a special case (because that single REST
+ * resource generates supports not just one thing, but many things — multiple
+ * entity types), but the same principles apply.
+ */
+abstract class ResourceTestBase extends BrowserTestBase {
+
+  /**
+   * The format to use in this test.
+   *
+   * A format is the combination of a certain normalizer and a certain
+   * serializer.
+   *
+   * @see https://www.drupal.org/developing/api/8/serialization
+   *
+   * (The default is 'json' because that doesn't depend on any module.)
+   *
+   * @var string
+   */
+  protected static $format = 'json';
+
+  /**
+   * The MIME type that corresponds to $format.
+   *
+   * (Sadly this cannot be computed automatically yet.)
+   *
+   * @var string
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * The expected MIME type in case of 4xx error responses.
+   *
+   * (Can be different, when $mimeType for example encodes a particular
+   * normalization, such as 'application/hal+json': its error response MIME
+   * type is 'application/json'.)
+   *
+   * @var string
+   */
+  protected static $expectedErrorMimeType = 'application/json';
+
+  /**
+   * The authentication mechanism to use in this test.
+   *
+   * (The default is 'cookie' because that doesn't depend on any module.)
+   *
+   * @var string
+   */
+  protected static $auth = FALSE;
+
+  /**
+   * The account to use for authentication, if any.
+   *
+   * @var null|\Drupal\Core\Session\AccountInterface
+   */
+  protected $account = NULL;
+
+  /**
+   * The REST resource config entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $resourceConfigStorage;
+
+  /**
+   * The serializer service.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * Modules to install.
+   *
+   * @var array
+   */
+  public static $modules = ['rest'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    // Ensure the anonymous user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert('[] === $user_role->getPermissions()', 'The anonymous user role has no permissions at all.');
+
+    if (static::$auth !== FALSE) {
+      // Ensure the authenticated user role has no permissions at all.
+      $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
+      foreach ($user_role->getPermissions() as $permission) {
+        $user_role->revokePermission($permission);
+      }
+      $user_role->save();
+      assert('[] === $user_role->getPermissions()', 'The authenticated user role has no permissions at all.');
+
+      // Create an account.
+      $this->account = $this->createUser();
+    }
+    else {
+      // Otherwise, also create an account, so that any test involving User
+      // entities will have the same user IDs regardless of authentication.
+      $this->createUser();
+    }
+
+    $this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config');
+
+    // Ensure there's a clean slate: delete all REST resource config entities.
+    $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple());
+  }
+
+  /**
+   * Provisions a REST resource.
+   *
+   * @param string $resource_type
+   *   The resource type (REST resource plugin ID).
+   * @param string[] $formats
+   *   The allowed formats for this resource.
+   * @param string[] $authentication
+   *   The allowed authentication providers for this resource.
+   */
+  protected function provisionResource($resource_type, $formats = [], $authentication = []) {
+    $this->resourceConfigStorage->create([
+      'id' => $resource_type,
+      'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+      'configuration' => [
+        'methods' => ['GET', 'POST', 'PATCH', 'DELETE'],
+        'formats' => $formats,
+        'authentication' => $authentication,
+      ]
+    ])->save();
+    // @todo Remove this in https://www.drupal.org/node/2815845.
+    drupal_flush_all_caches();
+  }
+
+  /**
+   * Sets up the necessary authorization.
+   *
+   * In case of a test verifying publicly accessible REST resources: grant
+   * permissions to the anonymous user role.
+   *
+   * In case of a test verifying behavior when using a particular authentication
+   * provider: create a user with a particular set of permissions.
+   *
+   * Because of the $method parameter, it's possible to first set up
+   * authentication for only GET, then add POST, et cetera. This then also
+   * allows for verifying a 403 in case of missing authorization.
+   *
+   * @param string $method
+   *   The HTTP method for which to set up authentication.
+   *
+   * @see ::grantPermissionsToAnonymousRole()
+   * @see ::grantPermissionsToAuthenticatedRole()
+   */
+  abstract protected function setUpAuthorization($method);
+
+  /**
+   * Verifies the error response in case of missing authentication.
+   */
+  abstract protected function assertResponseWhenMissingAuthentication(ResponseInterface $response);
+
+  /**
+   * Asserts normalization-specific edge cases.
+   *
+   * (Should be called before sending a well-formed request.)
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   */
+  abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options);
+
+  /**
+   * Asserts authentication provider-specific edge cases.
+   *
+   * (Should be called before sending a well-formed request.)
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   */
+  abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options);
+
+  /**
+   * Initializes authentication.
+   *
+   * E.g. for cookie authentication, we first need to get a cookie.
+   */
+  protected function initAuthentication() {}
+
+  /**
+   * Returns Guzzle request options for authentication.
+   *
+   * @param string $method
+   *   The HTTP method for this authenticated request.
+   *
+   * @return array
+   *   Guzzle request options to use for authentication.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getAuthenticationRequestOptions($method) {
+    return [];
+  }
+
+  /**
+   * Grants permissions to the anonymous role.
+   *
+   * @param string[] $permissions
+   *   Permissions to grant.
+   */
+  protected function grantPermissionsToAnonymousRole(array $permissions) {
+    $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions);
+  }
+
+  /**
+   * Grants permissions to the authenticated role.
+   *
+   * @param string[] $permissions
+   *   Permissions to grant.
+   */
+  protected function grantPermissionsToAuthenticatedRole(array $permissions) {
+    $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
+  }
+
+  /**
+   * Grants permissions to the tested role: anonymous or authenticated.
+   *
+   * @param string[] $permissions
+   *   Permissions to grant.
+   *
+   * @see ::grantPermissionsToAuthenticatedRole()
+   * @see ::grantPermissionsToAnonymousRole()
+   */
+  protected function grantPermissionsToTestedRole(array $permissions) {
+    if (static::$auth) {
+      $this->grantPermissionsToAuthenticatedRole($permissions);
+    }
+    else {
+      $this->grantPermissionsToAnonymousRole($permissions);
+    }
+  }
+
+  /**
+   * Performs a HTTP request. Wraps the Guzzle HTTP client.
+   *
+   * Why wrap the Guzzle HTTP client? Because we want to keep the actual test
+   * code as simple as possible, and hence not require them to specify the
+   * 'http_errors = FALSE' request option, nor do we want them to have to
+   * convert Drupal Url objects to strings.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   */
+  protected function request($method, Url $url, array $request_options) {
+    $request_options[RequestOptions::HTTP_ERRORS] = FALSE;
+    return $this->httpClient->request($method, $url->toString(), $request_options);
+  }
+
+  /**
+   * Asserts that a resource response has the given status code and body.
+   *
+   * (Also asserts that the expected error MIME type is present, but this is
+   * defined globally for the test via static::$expectedErrorMimeType, because
+   * all error responses should use the same MIME type.)
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string|false $expected_body
+   *   The expected response body. FALSE in case this should not be asserted.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response to assert.
+   */
+  protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response) {
+    $this->assertSame($expected_status_code, $response->getStatusCode());
+    if ($expected_status_code < 400) {
+      $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    }
+    else {
+      $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+    }
+    if ($expected_body !== FALSE) {
+      $this->assertSame($expected_body, (string) $response->getBody());
+    }
+  }
+
+  /**
+   * Asserts that a resource error response has the given message.
+   *
+   * (Also asserts that the expected error MIME type is present, but this is
+   * defined globally for the test via static::$expectedErrorMimeType, because
+   * all error responses should use the same MIME type.)
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string $expected_message
+   *   The expected error message.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The error response to assert.
+   */
+  protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) {
+    // @todo Fix this in https://www.drupal.org/node/2813755.
+    $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT];
+    $expected_body = $this->serializer->encode(['message' => $expected_message], static::$format, $encode_options);
+    $this->assertResourceResponse($expected_status_code, $expected_body, $response);
+  }
+
+}