 .../tests/src/Functional/AnonResourceTestTrait.php |  28 ++
 .../src/Functional/BasicAuthResourceTestTrait.php  |  30 ++
 .../EntityResource/Block/BlockHalJsonAnonTest.php  |  41 +++
 .../Block/BlockHalJsonBasicAuthTest.php            |  52 ++++
 .../EntityResource/Block/BlockJsonAnonTest.php     |  36 +++
 .../Block/BlockJsonBasicAuthTest.php               |  52 ++++
 .../EntityResource/Block/BlockResourceTestBase.php |  99 +++++++
 .../EntityResource/EntityResourceTestBase.php      | 309 +++++++++++++++++++++
 .../EntityResource/HalEntityNormalizationTrait.php |  56 ++++
 .../EntityResource/Node/NodeHalJsonAnonTest.php    | 110 ++++++++
 .../Node/NodeHalJsonBasicAuthTest.php              |  37 +++
 .../EntityResource/Node/NodeJsonAnonTest.php       |  36 +++
 .../EntityResource/Node/NodeJsonBasicAuthTest.php  |  53 ++++
 .../EntityResource/Node/NodeResourceTestBase.php   | 136 +++++++++
 .../EntityResource/Role/RoleHalJsonAnonTest.php    |  41 +++
 .../Role/RoleHalJsonBasicAuthTest.php              |  67 +++++
 .../EntityResource/Role/RoleJsonAnonTest.php       |  36 +++
 .../EntityResource/Role/RoleJsonBasicAuthTest.php  |  70 +++++
 .../EntityResource/Role/RoleResourceTestBase.php   |  49 ++++
 .../EntityResource/Term/TermHalJsonAnonTest.php    |  63 +++++
 .../Term/TermHalJsonBasicAuthTest.php              |  54 ++++
 .../EntityResource/Term/TermJsonAnonTest.php       |  36 +++
 .../EntityResource/Term/TermJsonBasicAuthTest.php  |  52 ++++
 .../EntityResource/Term/TermResourceTestBase.php   |  88 ++++++
 .../HalJsonBasicAuthWorkaroundFor2805281Trait.php  |  24 ++
 .../JsonBasicAuthWorkaroundFor2805281Trait.php     |  25 ++
 .../rest/tests/src/Functional/ResourceTestBase.php | 170 ++++++++++++
 27 files changed, 1850 insertions(+)

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..b8b04b1
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use Psr\Http\Message\ResponseInterface;
+
+trait AnonResourceTestTrait {
+
+  /**
+   * 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);
+  }
+
+  /**
+   * {@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..bb1c997
--- /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->assertErrorResponse(401, 'No authentication credentials provided.', $response);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
new file mode 100644
index 0000000..80b10aa
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['administer blocks']);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..6b613ab
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['administer blocks']);
+  }
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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..ebfe7ac
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php
@@ -0,0 +1,36 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['administer blocks']);
+  }
+
+}
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..c5b803e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php
@@ -0,0 +1,52 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['administer blocks']);
+  }
+
+  // @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..9430a35
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
@@ -0,0 +1,99 @@
+<?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 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.
+    if ($this->account) {
+      $block->setVisibilityConfig('user_role', [
+        'id' => 'user_role',
+        'roles' => [
+          'authenticated' => 'authenticated',
+        ],
+        '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' => [],
+    ];
+    if ($this->account) {
+      $normalization['dependencies']['module'][] = 'user';
+      $normalization['visibility']['user_role'] = [
+        'id' => 'user_role',
+        'roles' => [
+          'authenticated' => 'authenticated',
+        ],
+        'negate' => FALSE,
+        'context_mapping' => [
+          'user' => '@user.current_user_context:current_user',
+        ],
+      ];
+    }
+
+    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..9ada181
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,309 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Url;
+use Drupal\Tests\rest\Functional\ResourceTestBase;
+use GuzzleHttp\Exception\ClientException;
+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.
+ *
+ * @todo BC: 'restful get/post [entity type]' permission
+ */
+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();
+  }
+
+  /**
+   * 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() {
+    // @todo test what happens before provisioning. Edge case: node.
+
+    // Provision REST resource.
+    $this->provisionEntityResource();
+
+    /** @var \Psr\Http\Message\ResponseInterface $response */
+    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+    $url = $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityType . '/' . $this->entity->id());
+
+    // Requesting without the appropriate ?_format query argument.
+    try {
+      $response = $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions());
+    } catch (ClientException $e) {
+      $response = $e->getResponse();
+    }
+    // When the anonymous user accesses a canonical (HTML) route, we won't get a
+    // 406, but a 200, and that is appropriate when no format is specified.
+    if ($has_canonical_url && !$this->account) {
+      $this->assertSame(200, $response->getStatusCode());
+    }
+    else {
+      $this->assert406Response($response);
+    }
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+    // Verify that the entity exists: use a HEAD request.
+    $response = $this->httpClient->head($url->toString(), $this->getAuthenticationRequestOptions());
+    $this->assertSame(200, $response->getStatusCode());
+    if (!$this->account) {
+      $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache'));
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    }
+    $this->assertSame('', (string)$response->getBody());
+    $head_headers = $response->getHeaders();
+
+    // Now do a GET request. And because of the preceding HEAD request, this
+    // should be a Page Cache hit.
+    $response = $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions());
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    if (!$this->account) {
+      $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);
+
+    if ($this->account) {
+      // Try to read the entity without the necessary authentication.
+      try {
+        $this->httpClient->get($url->toString());
+        $this->fail('No error response for missing authentication!');
+      }
+      catch (ClientException $e) {
+        $this->verifyResponseWhenMissingAuthentication($e->getResponse());
+      }
+    }
+
+    // Try to read the entity in an unsupported format.
+    try {
+      $url->setOption('query', ['_format' => 'non_existing_format']);
+      $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions());
+      $this->fail('No 406 response received.');
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+      $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'));
+    }
+
+    // Repeat the same request, but this time with the format's MIME type in the
+    // Accept header. This time we should get a response with the expected error
+    // MIME type.
+    try {
+      $options = $this->getAuthenticationRequestOptions();
+      $options['headers']['Accept'] = static::$mimeType;
+      $url->setOption('query', ['_format' => 'non_existing_format']);
+      $this->httpClient->get($url->toString(), $options);
+      $this->fail('No 406 response received.');
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+      $this->assert406Response($response);
+      $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+    }
+
+    // Try to read an entity that does not exist.
+    $non_existing_entity_url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format);
+    $non_existing_entity_url->setRouteParameter(static::$entityType, 987654321);
+    $non_existing_entity_url->setOption('query', ['_format' => static::$format]);
+    try {
+      $this->httpClient->get($non_existing_entity_url->toString());
+      $this->fail('No 404 response received.');
+    }
+    catch (ClientException $e) {
+      $path = str_replace('987654321', '{' . static::$entityType . '}', $non_existing_entity_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->assertErrorResponse(404, $message, $e->getResponse());
+    }
+  }
+
+  /**
+   * Asserts that an error response has the given status code and 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 assertErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) {
+    $this->assertSame($expected_status_code, $response->getStatusCode());
+    $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+    // @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];
+    $this->assertSame($this->serializer->encode(['message' => $expected_message], static::$format, $encode_options), (string)$response->getBody());
+  }
+
+  // @todo try the following request bodies:
+  // 1. empty (only makes sense for DELETE and GET), assert 400
+  // 2. not parseable, assert 400
+  // 3. parseable but invalid (with various possible errors, possibly entity type specific ones?), assert 422
+  // 4. parseable and valid, assert 201
+  public function atestPost() {
+    // Attempt to use REST resource while not provisioned.
+    $this->performUnsafeOperation('POST');
+    $this->assertSame(404, $this->getSession()->getStatusCode());
+    $this->assertFalse(EntityType::loadMultiple(), 'No entity has been created in the database.');
+
+    // Provision REST resource.
+    $this->provisionEntityResource();
+
+    // Try as user without 'create' access for this entity type.
+    $this->performUnsafeOperation('POST');
+    $this->assertSame(403, $this->getSession()->getStatusCode());
+    $this->assertSession()->responseContains('not allowed …');
+
+    // Try as user with sufficient permissions.
+    $this->performUnsafeOperation('POST');
+    $this->assertSame(201, $this->getSession()->getStatusCode());
+    $this->assertSession()->responseContains('…');
+  }
+
+  /**
+   * 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/HalEntityNormalizationTrait.php b/core/modules/rest/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php
new file mode 100644
index 0000000..1dcea7e
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\Tests\rest\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/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
new file mode 100644
index 0000000..34b45a7
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\user\Entity\User;
+
+
+/**
+ * @group rest
+ */
+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 setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['access content']);
+  }
+
+  /**
+   * {@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()]
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..a6260bb
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class NodeHalJsonBasicAuthTest extends NodeHalJsonAnonTest {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['access content']);
+  }
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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..596dd1c
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php
@@ -0,0 +1,36 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['access content']);
+  }
+
+}
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..d293972
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php
@@ -0,0 +1,53 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['access content']);
+  }
+
+  // @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..1b4538b
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php
@@ -0,0 +1,136 @@
+<?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 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' => [
+      ],
+    ];
+  }
+
+  // methods to provide one-off expectations/data/…
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php
new file mode 100644
index 0000000..d8535d2
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * @group rest
+ */
+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';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['administer permissions']);
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..64e3fef
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['administer permissions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+    return [
+      // Edge case: the weight is not 2 like in the default normalization, but 3
+      // because setUpAuthentication() creates a new user role (which gets
+      // weight 2), and then the entity that is created for testing ends up with
+      // weight 3.
+      // This only affects user_role entity tests for authenticated users.
+      'weight' => 3,
+    ] + $default_normalization;
+  }
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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..103a208
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php
@@ -0,0 +1,36 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['administer permissions']);
+  }
+
+}
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..05a0150
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php
@@ -0,0 +1,70 @@
+<?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 setUpAuthentication() {
+    $this->account = $this->createUser(['administer permissions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $default_normalization = parent::getExpectedNormalizedEntity();
+    return [
+      // Edge case: the weight is not 2 like in the default normalization, but 3
+      // because setUpAuthentication() creates a new user role (which gets
+      // weight 2), and then the entity that is created for testing ends up with
+      // weight 3.
+      // This only affects user_role entity tests for authenticated users.
+      'weight' => 3,
+    ] + $default_normalization;
+  }
+
+  /**
+   * {@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..4255e2d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
@@ -0,0 +1,49 @@
+<?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 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/TermHalJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
new file mode 100644
index 0000000..3d0f78f
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\HalEntityNormalizationTrait;
+
+/**
+ * @group rest
+ */
+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 setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['access content']);
+  }
+
+  /**
+   * {@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',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..4df21b0
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\Tests\rest\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait;
+
+/**
+ * @group rest
+ */
+class TermHalJsonBasicAuthTest extends TermHalJsonAnonTest {
+
+  use HalEntityNormalizationTrait;
+  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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['access content']);
+  }
+
+  // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage.
+  use HalJsonBasicAuthWorkaroundFor2805281Trait {
+    HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait;
+  }
+
+}
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..60fa865
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php
@@ -0,0 +1,36 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->grantPermissionsToAnonymousRole(['access content']);
+  }
+
+}
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..84c5cf2
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php
@@ -0,0 +1,52 @@
+<?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';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthentication() {
+    $this->account = $this->createUser(['access content']);
+  }
+
+  // @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..f1c3bbc
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php
@@ -0,0 +1,88 @@
+<?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 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,
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/rest/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php
new file mode 100644
index 0000000..1140b22
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Tests\rest\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/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..22132c4
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\rest\RestResourceConfigInterface;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+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 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 has no permissions at all.');
+
+    // Then allow concrete tests to set up authentication: grant permissions to
+    // the anonymous user, or create a user with the necessary permissions.
+    $this->setUpAuthentication();
+
+    $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();
+
+    $this->container->get('router.builder')->rebuild();
+  }
+
+  /**
+   * Sets up the necessary authentication.
+   *
+   * 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.
+   *
+   * @return void
+   */
+  abstract protected function setUpAuthentication();
+
+  /**
+   * 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 [];
+  }
+
+}
