 .../rest/src/Tests/EntityResourceTestBase.php      | 223 +++++++++++++++++++++
 .../rest/src/Tests/NodeHalJsonBasicAuthTest.php    | 139 +++++++++++++
 .../rest/src/Tests/NodeJsonBasicAuthTest.php       |  37 ++++
 .../rest/src/Tests/NodeResourceTestBase.php        | 138 +++++++++++++
 .../rest/src/Tests/NodeXmlBasicAuthTest.php        |  43 ++++
 core/modules/rest/src/Tests/ResourceTestBase.php   | 116 +++++++++++
 6 files changed, 696 insertions(+)

diff --git a/core/modules/rest/src/Tests/EntityResourceTestBase.php b/core/modules/rest/src/Tests/EntityResourceTestBase.php
new file mode 100644
index 0000000..7416d16
--- /dev/null
+++ b/core/modules/rest/src/Tests/EntityResourceTestBase.php
@@ -0,0 +1,223 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Url;
+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 */
+    $url = $this->entity->toUrl();
+
+    // Requesting without the appropriate ?_format query argument.
+    $response = $this->httpClient->get($url->toString());
+    $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());
+      $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'));
+      $root_relative_url = str_replace(['987654321', \Drupal::request()->getSchemeAndHttpHost()], ['{' . static::$entityType . '}', ''], $non_existing_entity_url->setAbsolute()->setOption('query', [])->toString());
+      $message = ['message' => 'The "' . static::$entityType . '" parameter was not converted for the path "' . $root_relative_url . '" (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/src/Tests/NodeHalJsonBasicAuthTest.php b/core/modules/rest/src/Tests/NodeHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..3ea5d25
--- /dev/null
+++ b/core/modules/rest/src/Tests/NodeHalJsonBasicAuthTest.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\user\Entity\User;
+
+/**
+ * @group rest
+ */
+class NodeHalJsonBasicAuthTest extends NodeResourceTestBase {
+
+  /**
+   * {@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 = $default_normalization;
+
+    // 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) {
+      $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 (empty($normalization[$field_name])) {
+        unset($normalization[$field_name]);
+      }
+    }
+
+    $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/src/Tests/NodeJsonBasicAuthTest.php b/core/modules/rest/src/Tests/NodeJsonBasicAuthTest.php
new file mode 100644
index 0000000..c5607ee
--- /dev/null
+++ b/core/modules/rest/src/Tests/NodeJsonBasicAuthTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+/**
+ * @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/src/Tests/NodeResourceTestBase.php b/core/modules/rest/src/Tests/NodeResourceTestBase.php
new file mode 100644
index 0000000..e900509
--- /dev/null
+++ b/core/modules/rest/src/Tests/NodeResourceTestBase.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\user\Entity\User;
+
+/**
+ * @group rest
+ */
+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/src/Tests/NodeXmlBasicAuthTest.php b/core/modules/rest/src/Tests/NodeXmlBasicAuthTest.php
new file mode 100644
index 0000000..5e2df00
--- /dev/null
+++ b/core/modules/rest/src/Tests/NodeXmlBasicAuthTest.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+/**
+ * @group rest
+ */
+class NodeXmlBasicAuthTest extends NodeResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $expectedErrorMimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    return array_map(function ($value) { return isset($value[0]) ? $value[0] : ''; }, parent::getExpectedNormalizedEntity());
+  }
+
+  // methods to provide one-off expectations/data/…
+}
diff --git a/core/modules/rest/src/Tests/ResourceTestBase.php b/core/modules/rest/src/Tests/ResourceTestBase.php
new file mode 100644
index 0000000..fe8e53b
--- /dev/null
+++ b/core/modules/rest/src/Tests/ResourceTestBase.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\rest\Tests;
+
+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();
+  }
+
+}
