 .../Block/BlockHalJsonBasicAuthTest.php            |  35 +++
 .../Block/BlockJsonBasicAuthTest.php               |  35 +++
 .../EntityResource/Block/BlockResourceTestBase.php |  70 ++++++
 .../EntityResource/EntityResourceTestBase.php      | 235 +++++++++++++++++++++
 .../EntityResource/HalEntityNormalizationTrait.php |  56 +++++
 .../Node/NodeHalJsonBasicAuthTest.php              | 105 +++++++++
 .../EntityResource/Node/NodeJsonBasicAuthTest.php  |  37 ++++
 .../EntityResource/Node/NodeResourceTestBase.php   | 136 ++++++++++++
 .../Role/RoleHalJsonBasicAuthTest.php              |  35 +++
 .../EntityResource/Role/RoleJsonBasicAuthTest.php  |  35 +++
 .../EntityResource/Role/RoleResourceTestBase.php   |  55 +++++
 .../Term/TermHalJsonBasicAuthTest.php              |  59 ++++++
 .../EntityResource/Term/TermJsonBasicAuthTest.php  |  35 +++
 .../EntityResource/Term/TermResourceTestBase.php   |  88 ++++++++
 .../rest/tests/src/Functional/ResourceTestBase.php | 116 ++++++++++
 15 files changed, 1132 insertions(+)

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..25512fa
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+/**
+ * @group rest
+ */
+class BlockHalJsonBasicAuthTest extends BlockResourceTestBase {
+
+  /**
+   * {@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';
+
+}
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..71a5e81
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Block;
+
+/**
+ * @group rest
+ */
+class BlockJsonBasicAuthTest extends BlockResourceTestBase {
+
+  /**
+   * {@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';
+
+}
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..5f76780
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php
@@ -0,0 +1,70 @@
+<?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',
+    ]);
+    $block->save();
+
+    // Give anonymous users permission to view comments, so that we can verify
+    // the cache tags of cached versions of comment pages.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    $user_role->grantPermission('administer blocks');
+    $user_role->save();
+
+    return $block;
+  }
+
+  protected function getExpectedNormalizedEntity() {
+    assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.');
+    return [
+      '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' => [],
+    ];
+  }
+
+}
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..29632a7
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,235 @@
+<?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;
+
+/**
+ * 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() {
+    $this->provisionResource('entity.' . static::$entityType, [static::$format], [static::$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());
+    } catch (ClientException $e) {
+      if (!$has_canonical_url) {
+        $response = $e->getResponse();
+        $this->assertSame(406, $response->getStatusCode());
+      }
+    }
+    if ($has_canonical_url) {
+      $this->assertSame(200, $response->getStatusCode());
+    }
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+    // Verify that the entity exists: use a HEAD request.
+    $response = $this->httpClient->head($url->toString());
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertSame(['MISS'], $response->getHeader('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->assertSame(200, $response->getStatusCode());
+    $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+    $this->assertSame(['HIT'], $response->getHeader('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-Page-Cache'];
+    foreach ($ignored_headers as $ignored_header) {
+      unset($head_headers[$ignored_header]);
+      unset($get_headers[$ignored_header]);
+    }
+    $this->assertSame($get_headers, $head_headers);
+
+    // Try to read the entity in an unsupported format.
+    try {
+      $url->setOption('query', ['_format' => 'non_existing_format']);
+      $this->httpClient->get($url->toString());
+      $this->fail('No 406 response received.');
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+      $this->assertSame(406, $response->getStatusCode());
+      // @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.
+    try {
+      $headers = ['Accept' => static::$mimeType];
+      $url->setOption('query', ['_format' => 'non_existing_format']);
+      $this->httpClient->get($url->toString(), ['headers' => $headers]);
+      $this->fail('No 406 response received.');
+    }
+    catch (ClientException $e) {
+      $response = $e->getResponse();
+      $this->assertSame(406, $response->getStatusCode());
+      $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) {
+      $response = $e->getResponse();
+      $this->assertSame(404, $response->getStatusCode());
+      $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+      $path = str_replace('987654321', '{' . static::$entityType . '}', $non_existing_entity_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+      $message = ['message' => 'The "' . static::$entityType . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityType . '.GET.' . static::$format . '")'];
+      // @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, 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('…');
+  }
+
+  /**
+   * 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/NodeHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..9b68851
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+use Drupal\Tests\rest\Functional\EntityResource\HalEntityNormalizationTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * @group rest
+ */
+class NodeHalJsonBasicAuthTest extends NodeResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+
+  /**
+   * {@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 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()]
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+  // methods to provide one-off expectations/data/…
+}
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..f4a4dcc
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Node;
+
+/**
+ * @group rest
+ */
+class NodeJsonBasicAuthTest extends NodeResourceTestBase {
+
+  /**
+   * {@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';
+
+
+  // methods to provide one-off expectations/data/…
+}
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/RoleHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..9021546
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+/**
+ * @group rest
+ */
+class RoleHalJsonBasicAuthTest extends RoleResourceTestBase {
+
+  /**
+   * {@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';
+
+}
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..8a94f54
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Role;
+
+/**
+ * @group rest
+ */
+class RoleJsonBasicAuthTest extends RoleResourceTestBase {
+
+  /**
+   * {@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';
+
+}
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..8e6388c
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php
@@ -0,0 +1,55 @@
+<?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();
+
+    // Give anonymous users permission to view comments, so that we can verify
+    // the cache tags of cached versions of comment pages.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    $user_role->grantPermission('administer permissions');
+    $user_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/TermHalJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..06d36ba
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+use Drupal\Tests\rest\Functional\EntityResource\HalEntityNormalizationTrait;
+
+/**
+ * @group rest
+ */
+class TermHalJsonBasicAuthTest extends TermResourceTestBase {
+
+  use HalEntityNormalizationTrait;
+
+  /**
+   * {@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 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/TermJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php
new file mode 100644
index 0000000..bd352d3
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional\EntityResource\Term;
+
+/**
+ * @group rest
+ */
+class TermJsonBasicAuthTest extends TermResourceTestBase {
+
+  /**
+   * {@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';
+
+}
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/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000..5f178a2
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\Tests\rest\Functional;
+
+use Drupal\rest\RestResourceConfigInterface;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Subclass this for every REST resource, every format and every auth mechanism.
+ */
+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 = 'cookie';
+
+  /**
+   * 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();
+
+    $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();
+  }
+
+}
