 .../EntityResource/Block/BlockHalJsonAnonTest.php  |  35 ++
 .../Block/BlockHalJsonBasicAuthTest.php            |  46 ++
 .../ConfigTest/ConfigTestHalJsonAnonTest.php       |  35 ++
 .../ConfigTest/ConfigTestHalJsonBasicAuthTest.php  |  46 ++
 .../EntityTest/EntityTestHalJsonAnonTest.php       |  96 ++++
 .../EntityTest/EntityTestHalJsonBasicAuthTest.php  |  30 ++
 .../EntityResource/HalEntityNormalizationTrait.php |  56 +++
 .../EntityResource/Node/NodeHalJsonAnonTest.php    | 117 +++++
 .../Node/NodeHalJsonBasicAuthTest.php              |  30 ++
 .../EntityResource/Role/RoleHalJsonAnonTest.php    |  35 ++
 .../Role/RoleHalJsonBasicAuthTest.php              |  46 ++
 .../EntityResource/Term/TermHalJsonAnonTest.php    |  70 +++
 .../Term/TermHalJsonBasicAuthTest.php              |  30 ++
 .../Vocabulary/VocabularyHalJsonAnonTest.php       |  39 ++
 .../Vocabulary/VocabularyHalJsonBasicAuthTest.php  |  46 ++
 .../HalJsonBasicAuthWorkaroundFor2805281Trait.php  |  24 +
 .../config_test_rest/config_test_rest.info.yml     |   7 +
 .../config_test_rest/config_test_rest.module       |  26 ++
 .../config_test_rest.permissions.yml               |   2 +
 .../tests/src/Functional/AnonResourceTestTrait.php |  16 +
 .../src/Functional/BasicAuthResourceTestTrait.php  |  30 ++
 .../EntityResource/Block/BlockJsonAnonTest.php     |  29 ++
 .../Block/BlockJsonBasicAuthTest.php               |  45 ++
 .../EntityResource/Block/BlockResourceTestBase.php |  96 ++++
 .../ConfigTest/ConfigTestJsonAnonTest.php          |  29 ++
 .../ConfigTest/ConfigTestJsonBasicAuthTest.php     |  45 ++
 .../ConfigTest/ConfigTestResourceTestBase.php      |  61 +++
 .../EntityResource/EntityResourceTestBase.php      | 491 +++++++++++++++++++++
 .../EntityTest/EntityTestJsonAnonTest.php          |  29 ++
 .../EntityTest/EntityTestJsonBasicAuthTest.php     |  46 ++
 .../EntityTest/EntityTestResourceTestBase.php      | 111 +++++
 .../EntityResource/Node/NodeJsonAnonTest.php       |  29 ++
 .../EntityResource/Node/NodeJsonBasicAuthTest.php  |  46 ++
 .../EntityResource/Node/NodeResourceTestBase.php   | 167 +++++++
 .../EntityResource/Role/RoleJsonAnonTest.php       |  29 ++
 .../EntityResource/Role/RoleJsonBasicAuthTest.php  |  48 ++
 .../EntityResource/Role/RoleResourceTestBase.php   |  56 +++
 .../EntityResource/Term/TermJsonAnonTest.php       |  29 ++
 .../EntityResource/Term/TermJsonBasicAuthTest.php  |  45 ++
 .../EntityResource/Term/TermResourceTestBase.php   | 121 +++++
 .../Vocabulary/VocabularyJsonAnonTest.php          |  33 ++
 .../Vocabulary/VocabularyJsonBasicAuthTest.php     |  46 ++
 .../Vocabulary/VocabularyResourceTestBase.php      |  61 +++
 .../JsonBasicAuthWorkaroundFor2805281Trait.php     |  25 ++
 .../rest/tests/src/Functional/ResourceTestBase.php | 306 +++++++++++++
 45 files changed, 2885 insertions(+)

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 0000000..d0758f3
--- /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 0000000..d4f8a12
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..45cedb2
--- /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 0000000..c24397a
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..cb0d544
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest;
+
+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 getNormalizedEntityToCreate() {
+    return parent::getNormalizedEntityToCreate() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/entity_test/entity_test',
+        ],
+      ],
+    ];
+  }
+
+}
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 0000000..063e1e3
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..49ac07d
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource;
+
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+
+trait HalEntityNormalizationTrait {
+
+  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) {
+      if ($field->getName() === $bundle_key) {
+        return FALSE;
+      }
+      return $field instanceof EntityReferenceFieldItemListInterface;
+    }));
+    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.
+    foreach ($normalization as $field_name => $data) {
+      if ($this->entity->$field_name->isEmpty()) {
+        unset($normalization[$field_name]);
+      }
+    }
+
+    return $normalization;
+  }
+
+}
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 0000000..cb22d58
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Node;
+
+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 function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+
+    $normalization = $this->applyHalFieldNormalization($default_normalization);
+
+    $author = User::load(0);
+    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/0?_format=hal_json',
+            'lang' => 'en',
+          ],
+        ],
+        $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+          [
+            'href' => $this->baseUrl . '/user/0?_format=hal_json',
+          ],
+        ],
+      ],
+      '_embedded' => [
+        $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+          [
+            '_links' => [
+              'self' => [
+                'href' => $this->baseUrl . '/user/0?_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/0?_format=hal_json',
+              ],
+              'type' => [
+                'href' => $this->baseUrl . '/rest/type/user/user',
+              ],
+            ],
+            'uuid' => [
+              ['value' => $author->uuid()]
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedEntityToCreate() {
+    return parent::getNormalizedEntityToCreate() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/node/camelids',
+        ],
+      ],
+    ];
+  }
+
+}
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 0000000..1132ee0
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..cad4104
--- /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 0000000..9b9932f
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..6fd2279
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Drupal\Tests\hal\Functional\EntityResource\Term;
+
+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 getNormalizedEntityToCreate() {
+    return parent::getNormalizedEntityToCreate() + [
+      '_links' => [
+        'type' => [
+          'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids',
+        ],
+      ],
+    ];
+  }
+
+}
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 0000000..59b754a
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..910da05
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php
@@ -0,0 +1,39 @@
+<?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';
+
+  // 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() {}
+
+}
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 0000000..03c9232
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..c4c6e39
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php
@@ -0,0 +1,24 @@
+<?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 verifyResponseWhenMissingAuthentication(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 0000000..cf9efee
--- /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 0000000..f7e24a2
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module
@@ -0,0 +1,26 @@
+<?php
+
+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 0000000..b8fd229
--- /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/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
new file mode 100644
index 0000000..cc089de
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Psr\Http\Message\ResponseInterface;
+
+trait AnonResourceTestTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) {
+    throw new \LogicException('When testing for anonymous users, authentication cannot be missing.');
+  }
+
+}
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 0000000..da62ff8
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * ResourceTestBase::getAuthenticationRequestOptions() for basic_auth.
+ */
+trait BasicAuthResourceTestTrait {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAuthenticationRequestOptions() {
+    return [
+      'headers' => [
+        'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) {
+    $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
+  }
+
+}
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 0000000..9c764bd
--- /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 0000000..a29c38d
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..5ef4512
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\block\Entity\Block;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+abstract class BlockResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityType = 'block';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->entity->setVisibilityConfig('user_role', [])->save();
+        break;
+      case 'POST':
+        $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 Investigate further.
+    $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;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    $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;
+  }
+
+}
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 0000000..db79e6c
--- /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 0000000..bd73278
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..be91643
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\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 $entityType = 'config_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['view config_test']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $config_test = entity_create('config_test', [
+      'id' => 'llama',
+      'label' => 'Llama',
+    ]);
+    $config_test->save();
+
+    return $config_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    $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;
+  }
+
+}
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 0000000..aa2b3ca
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,491 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Url;
+use Drupal\Tests\rest\Functional\ResourceTestBase;
+use GuzzleHttp\Exception\ClientException;
+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.
+ */
+abstract class EntityResourceTestBase extends ResourceTestBase {
+
+  protected static $entityType = NULL;
+
+  /**
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $httpClient;
+
+  /**
+   * The main entity used for testing.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+
+  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::$entityType, [static::$format], $auth);
+  }
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('serializer');
+
+    // Set up a HTTP client that accepts relative URLs.
+    $this->httpClient = $this->container->get('http_client_factory')
+      ->fromOptions(['base_uri' => $this->baseUrl]);
+
+    // Add field with specific allowed value.
+    // (allows testing invalid vs valid field value)
+
+    // Add access-protected field to entity type.
+    // (allows testing with field that cannot be modified)
+
+    // Create an entity.
+    $this->entity = $this->createEntity();
+
+    // @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();
+
+  public function testGet() {
+    $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.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+    if ($has_canonical_url) {
+      $this->assertSame(403, $response->getStatusCode());
+    }
+    else {
+      $this->assertSame(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.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+    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) {
+      $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->verifyResponseWhenMissingAuthentication($response);
+    }
+
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions());
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('GET', $url, $request_options);
+    // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+    $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'));
+    }
+    // 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, that 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);
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityType]);
+
+
+    // 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);
+    // @todo this works fine locally, but on testbot it comes back with 'text/plain; charset=UTF-8'. WTF.
+//      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+
+    $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+
+
+    // DX: 406 when requesting unsupported format but specifying Accept header.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assert406Response($response);
+    $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+    $url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format);
+    $url->setRouteParameter(static::$entityType, 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::$entityType . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "' . static::$entityType . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityType . '.GET.' . static::$format . '")';
+    $this->assertResourceErrorResponse(404, $message, $response);
+  }
+
+  /**
+   * 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::$entityType . '/' . $this->entity->id());
+  }
+
+  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;
+    }
+
+    $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->getNormalizedEntityToCreate(), static::$format);
+    $parseable_invalid_request_body = $this->serializer->encode($this->getInvalidNormalizedEntityToCreate(), 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);
+    // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+    $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->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('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 use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+//    $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->verifyResponseWhenMissingAuthentication($response);
+    }
+
+
+    $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions());
+
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->setUpAuthorization('POST');
+
+
+    // DX: 422 when invalid entity.
+    $response = $this->request('POST', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->getKey('label');
+    $label_field_capitalized = ucfirst($label_field);
+    // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+//    $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_valid_request_body;
+
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertSame([str_replace($this->entity->id(), 2, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+
+
+    $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('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(403, '', $response);
+
+
+    $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityType]);
+
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response);
+    $this->assertSame([str_replace($this->entity->id(), 3, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+  }
+
+  /**
+   * 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::$entityType);
+  }
+
+  /**
+   * Decorates ::getNormalizedEntityToCreate().
+   */
+  protected function getInvalidNormalizedEntityToCreate() {
+    $normalization = $this->getNormalizedEntityToCreate();
+
+    // Add a second label to this entity to make it invalid.
+    $label_field = $this->entity->getEntityType()->getKey('label');
+    $normalization[$label_field][1]['value'] = 'Second Title';
+
+    return $normalization;
+  }
+
+  /**
+   * 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());
+    }
+  }
+
+  /**
+   * Simulate common developer mistake when performing an unsafe operation:
+   * - forget to specify the X-CSRF-Token request header
+   * - specify in invalid X-CSRF-Token request header value
+   *
+   * In either case, the REST module must provide meaningful feedback for DX.
+   */
+  protected function performUnsafeOperation($method) {
+    // Try without CSRF token
+    // …request
+    $this->assertSame(403, $this->getSession()->getStatusCode());
+    $this->assertSession()->responseContains('X-CSRF-Token request header is missing');
+    // Try with invalid CSRF token
+    // …request
+    $this->assertSame(403, $this->getSession()->getStatusCode());
+    $this->assertSession()->responseContains('X-CSRF-Token request header is invalid');
+    // Try with valid CSRF token
+    // …request
+  }
+
+}
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 0000000..a7e4420
--- /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 0000000..1b45e25
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+
+}
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 0000000..deaf1e8
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php
@@ -0,0 +1,111 @@
+<?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 $entityType = 'entity_test';
+
+  /**
+   * {@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;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $entity_test = EntityTest::create(array(
+      'name' => 'Llama',
+      'type' => 'entity_test',
+    ));
+    $entity_test->setOwnerId(0);
+    $entity_test->save();
+
+    return $entity_test;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    $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 getNormalizedEntityToCreate() {
+    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 0000000..24d47c4
--- /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 0000000..0200d49
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * @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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..f32105e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
@@ -0,0 +1,167 @@
+<?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 $entityType = 'node';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Camelids" node type.
+    NodeType::create([
+      'name' => 'Camelids',
+      'type' => 'camelids',
+    ])->save();
+
+    // Create a "Llama" node.
+    $node = Node::create(['type' => 'camelids']);
+    $node->setTitle('Llama')
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789)
+      ->setRevisionCreationTime(123456789)
+      ->save();
+
+    return $node;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    $author = User::load(0);
+    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' => '0',
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/0',
+        ],
+      ],
+      'revision_uid' => [
+        [
+          'target_id' => '0',
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/0',
+        ],
+      ],
+      'revision_log' => [
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedEntityToCreate() {
+    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 0000000..3ec96eb
--- /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 0000000..5cc463c
--- /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 verifyResponseWhenMissingAuthentication(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/RoleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
new file mode 100644
index 0000000..f5406d4
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+abstract class RoleResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityType = 'user_role';
+
+  /**
+   * {@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;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    return [
+      'uuid' => $this->entity->uuid(),
+      'weight' => 2,
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [],
+      'id' => 'llama',
+      'label' => NULL,
+      'is_admin' => NULL,
+      'permissions' => [],
+    ];
+  }
+
+}
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 0000000..6e01c03
--- /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 0000000..6ee100a
--- /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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..77372df
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
@@ -0,0 +1,121 @@
+<?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 $entityType = 'taxonomy_term';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+        // @todo Create issue similar to https://www.drupal.org/node/2808217.
+        $this->grantPermissionsToTestedRole(['administer taxonomy']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // 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;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    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 getNormalizedEntityToCreate() {
+    return [
+      'vid' => [
+        [
+          'target_id' => 'camelids',
+        ],
+      ],
+      'name' => [
+        [
+          'value' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+}
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 0000000..ccda474
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php
@@ -0,0 +1,33 @@
+<?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() {}
+
+}
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 0000000..8549963
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
+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::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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 0000000..235dc7d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary;
+
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\Entity\User;
+use Drupal\user\RoleInterface;
+
+abstract class VocabularyResourceTestBase extends EntityResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityType = 'taxonomy_vocabulary';
+
+  /**
+   * {@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() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    return [
+      'uuid' => $this->entity->uuid(),
+      'vid' => 'llama',
+      'langcode' => 'en',
+      'status' => TRUE,
+      'dependencies' => [],
+      'name' => 'Llama',
+      'description' => NULL,
+      'hierarchy' => 0,
+      'weight' => 0,
+    ];
+  }
+
+}
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 0000000..2d76a71
--- /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 verifyResponseWhenMissingAuthentication(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 0000000..1c212bb
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,306 @@
+<?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\Exception\ClientException;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Subclass this for every REST resource, every format and every auth mechanism.
+ */
+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 [format=serializer+normalizer docs]
+   * @todo what about edge cases when multiple formats are enabled, e.g. Accepting one format, but sending with a different Content-Type?
+   *
+   * (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
+   *
+   * @todo it SHOULD be possible to iterate over all authentication mechanisms and do all of those in a single test? The problem is that we'd then have to enable all modules that provide auth mechanisms. Which can include contrib. So doing a separate test per auth mechanism makes it easier for contrib to add tests.
+   */
+  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();
+    }
+
+    $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.
+   *
+   * @return void
+   *
+   * @see ::grantPermissionsToAnonymousRole()
+   * @see ::grantPermissionsToAuthenticatedRole()
+   */
+  abstract protected function setUpAuthorization($method);
+
+  /**
+   * Verifies the error response in case of missing authentication.
+   *
+   * @return void
+   */
+  abstract protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response);
+
+  /**
+   * Returns Guzzle request options for authentication.
+   *
+   * @return array
+   *   Guzzle request options to use for authentication.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getAuthenticationRequestOptions() {
+    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 any error response is returned via
+   * an exception, which would make the tests unnecessarily complex to read.
+   *
+   * @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) {
+    try {
+      $response = $this->httpClient->request($method, $url->toString(), $request_options);
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+    }
+    return $response;
+  }
+
+  /**
+   * 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 Either add this to \Drupal\serialization\Encoder\JsonEncoder, or
+    //   figure out how to let tests specify encoder options, and figure out
+    //   whether they should apply to just error responses or to everything.
+    $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);
+  }
+
+}
