 jsonapi.api.php                             |    6 +
 src/EntityToJsonApi.php                     |    3 +-
 src/Normalizer/HttpExceptionNormalizer.php  |    4 +-
 src/ResourceType/ResourceTypeRepository.php |    6 +
 tests/src/Functional/BlockTest.php          |  180 ++++
 tests/src/Functional/CommentTest.php        |  360 ++++++++
 tests/src/Functional/ConfigTestTest.php     |  100 ++
 tests/src/Functional/EntityTestTest.php     |  166 ++++
 tests/src/Functional/NodeTest.php           |  283 ++++++
 tests/src/Functional/ResourceTestBase.php   | 1302 +++++++++++++++++++++++++++
 tests/src/Functional/RoleTest.php           |   98 ++
 tests/src/Functional/TermTest.php           |  366 ++++++++
 tests/src/Functional/UserTest.php           |  359 ++++++++
 tests/src/Functional/VocabularyTest.php     |  108 +++
 14 files changed, 3339 insertions(+), 2 deletions(-)

diff --git a/jsonapi.api.php b/jsonapi.api.php
index 67d5dfb..15aa396 100644
--- a/jsonapi.api.php
+++ b/jsonapi.api.php
@@ -85,6 +85,7 @@
  * - Custom field normalization is not supported; only normalizers at the
  *   "DataType" plugin level are supported (these are a level below field
  *   types).
+ * - All available authentication mechanisms are allowed.
  *
  * The JSON API module does provide a PHP API to generate a JSON API
  * representation of entities:
@@ -122,5 +123,10 @@
  *
  * @see http://jsonapi.org/faq/#what-is-the-meaning-of-json-apis-version
  *
+ * Tests: subclasses of base test classes may contain BC breaks between minor
+ * releases, to allow minor releases to A) comply better with the JSON API spec,
+ * B) guarantee that all resource types (and therefore entity types) function as
+ * expected, C) update to future versions of the JSON API spec.
+ *
  * @}
  */
diff --git a/src/EntityToJsonApi.php b/src/EntityToJsonApi.php
index 7a6bd16..09f97be 100644
--- a/src/EntityToJsonApi.php
+++ b/src/EntityToJsonApi.php
@@ -98,7 +98,8 @@ class EntityToJsonApi {
    */
   protected function calculateContext(EntityInterface $entity) {
     // TODO: Supporting includes requires adding the 'include' query string.
-    $request = new Request();
+    $path = sprintf('/jsonapi/%s/%s/%s', $entity->getEntityTypeId(), $entity->bundle(), $entity->uuid());
+    $request = Request::create($path, 'GET');
     return [
       'account' => $this->currentUser,
       'cacheable_metadata' => new CacheableMetadata(),
diff --git a/src/Normalizer/HttpExceptionNormalizer.php b/src/Normalizer/HttpExceptionNormalizer.php
index f56040f..409fff0 100644
--- a/src/Normalizer/HttpExceptionNormalizer.php
+++ b/src/Normalizer/HttpExceptionNormalizer.php
@@ -102,8 +102,10 @@ class HttpExceptionNormalizer extends NormalizerBase {
    *
    * @return string
    *   URL pointing to the specific RFC-2616 section.
+   *
+   * @internal
    */
-  protected function getInfoUrl($status_code) {
+  public static function getInfoUrl($status_code) {
     // Depending on the error code we'll return a different URL.
     $url = 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html';
     $sections = [
diff --git a/src/ResourceType/ResourceTypeRepository.php b/src/ResourceType/ResourceTypeRepository.php
index 43614bb..eec82a9 100644
--- a/src/ResourceType/ResourceTypeRepository.php
+++ b/src/ResourceType/ResourceTypeRepository.php
@@ -73,6 +73,12 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
     $this->entityFieldManager = $entity_field_manager;
   }
 
+  // @todo implement \Drupal\Core\Plugin\CachedDiscoveryClearerInterface?
+  // @todo implement \Drupal\Component\Plugin\Discovery\DiscoveryInterface?
+  public function clearCachedDefinitions() {
+    $this->all = [];
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/tests/src/Functional/BlockTest.php b/tests/src/Functional/BlockTest.php
new file mode 100644
index 0000000..5a3b78e
--- /dev/null
+++ b/tests/src/Functional/BlockTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\block\Entity\Block;
+use Drupal\Core\Url;
+
+/**
+ * @group jsonapi
+ */
+class BlockTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'block';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'block--block';
+
+  /**
+   * @var \Drupal\block\BlockInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->entity->setVisibilityConfig('user_role', [])->save();
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $block = Block::create([
+      'plugin' => 'llama_block',
+      'region' => 'header',
+      'id' => 'llama',
+      'theme' => 'classy',
+    ]);
+    // All blocks can be viewed by the anonymous user by default. An interesting
+    // side effect of this is that any anonymous user is also able to read the
+    // corresponding block config entity via REST, even if an authentication
+    // provider is configured for the block config entity REST resource! In
+    // other words: Block entities do not distinguish between 'view' as in
+    // "render on a page" and 'view' as in "read the configuration".
+    // This prevents that.
+    // @todo Fix this in https://www.drupal.org/node/2820315.
+    $block->setVisibilityConfig('user_role', [
+      'id' => 'user_role',
+      'roles' => ['non-existing-role' => 'non-existing-role'],
+      'negate' => FALSE,
+      'context_mapping' => [
+        'user' => '@user.current_user_context:current_user',
+      ],
+    ]);
+    $block->save();
+
+    return $block;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/block/block/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'block--block',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 'llama',
+          'weight' => NULL,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [
+            // @todo Remove this first line in favor of the 3 commented lines in https://www.drupal.org/project/jsonapi/issues/2942979
+            'classy',
+//            'theme' => [
+//              'classy',
+//            ],
+          ],
+          'theme' => 'classy',
+          'region' => 'header',
+          'provider' => NULL,
+          'plugin' => 'llama_block',
+          'settings' => [
+            'id' => 'broken',
+            'label' => '',
+            'provider' => 'core',
+            'label_display' => 'visible',
+          ],
+          'visibility' => [],
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update once https://www.drupal.org/node/2300677 is fixed.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // @see ::createEntity()
+    // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342.
+//    return ['url.site'];
+    return parent::getExpectedCacheContexts();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags() {
+    // Because the 'user.permissions' cache context is missing, the cache tag
+    // for the anonymous user role is never added automatically.
+    return array_values(array_diff(parent::getExpectedCacheTags(), ['config:user.role.anonymous']));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return '';
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\block\BlockAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->setCacheTags([
+        '4xx-response',
+        'config:block.block.llama',
+        'http_response',
+        'user:2',
+      ])
+      ->setCacheContexts(['user.roles']);
+  }
+
+}
diff --git a/tests/src/Functional/CommentTest.php b/tests/src/Functional/CommentTest.php
new file mode 100644
index 0000000..07930a5
--- /dev/null
+++ b/tests/src/Functional/CommentTest.php
@@ -0,0 +1,360 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\comment\Entity\Comment;
+use Drupal\comment\Entity\CommentType;
+use Drupal\comment\Tests\CommentTestTrait;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * @group jsonapi
+ */
+class CommentTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+  use CommentTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['comment', 'entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'comment';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'comment--comment';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'status' => NULL,
+    // @todo These are relationships, and cannot be tested in the same way. Fix in https://www.drupal.org/project/jsonapi/issues/2939810.
+//    'pid' => NULL,
+//    'entity_id' => NULL,
+//    'uid' => NULL,
+    'name' => NULL,
+    'homepage' => NULL,
+    'created' => NULL,
+    'changed' => NULL,
+    'thread' => NULL,
+    'entity_type' => NULL,
+    'field_name' => NULL,
+  ];
+
+  /**
+   * @var \Drupal\comment\CommentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access comments', 'view test entity']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['post comments']);
+        break;
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['edit own comments']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer comments']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "bar" bundle for the "entity_test" entity type and create.
+    $bundle = 'bar';
+    entity_test_create_bundle($bundle, NULL, 'entity_test');
+
+    // Create a comment field on this bundle.
+    $this->addDefaultCommentField('entity_test', 'bar', 'comment');
+
+    // Create a "Camelids" test entity that the comment will be assigned to.
+    $commented_entity = EntityTest::create([
+      'name' => 'Camelids',
+      'type' => 'bar',
+    ]);
+    $commented_entity->save();
+
+    // Create a "Llama" comment.
+    $comment = Comment::create([
+      'comment_body' => [
+        'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+        'format' => 'plain_text',
+      ],
+      'entity_id' => $commented_entity->id(),
+      'entity_type' => 'entity_test',
+      'field_name' => 'comment',
+    ]);
+    $comment->setSubject('Llama')
+      ->setOwnerId($this->account->id())
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789);
+    $comment->save();
+
+    return $comment;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/comment/comment/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'comment--comment',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'cid' => 1,
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'created' => $this->formatExpectedTimestampItemValues(123456789),
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'comment_body' => [
+            'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+            'format' => 'plain_text',
+            // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2921257.
+//            'processed' => "<p>The name "llama" was adopted by European settlers from native Peruvians.</p>\n",
+          ],
+          'default_langcode' => TRUE,
+          'entity_type' => 'entity_test',
+          'field_name' => 'comment',
+          'homepage' => NULL,
+          'langcode' => 'en',
+          'name' => NULL,
+          'status' => TRUE,
+          'subject' => 'Llama',
+          'thread' => '01/',
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'comment_type' => [
+            'data' => [
+              'id' => CommentType::load('comment')->uuid(),
+              'type' => 'comment_type--comment_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/comment_type',
+              'self' => $self_url . '/relationships/comment_type',
+            ],
+          ],
+          'entity_id' => [
+            'data' => [
+              'id' => EntityTest::load(1)->uuid(),
+              'type' => 'entity_test--bar',
+            ],
+            'links' => [
+              'related' => $self_url . '/entity_id',
+              'self' => $self_url . '/relationships/entity_id',
+            ],
+          ],
+          'pid' => [
+            'data' => NULL,
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'type' => 'comment--comment',
+        'attributes' => [
+          'entity_type' => 'entity_test',
+          'field_name' => 'comment',
+          'subject' => 'Dramallama',
+          'comment_body' => [
+            'value' => 'Llamas are awesome.',
+            'format' => 'plain_text',
+          ],
+        ],
+        'relationships' => [
+          'entity_id' => [
+            'data' => [
+              'type' => 'entity_test--bar',
+              'id' => EntityTest::load(1)->uuid(),
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags() {
+    // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342.
+//    return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:filter.format.plain_text']);
+    return parent::getExpectedCacheTags();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342.
+//    return Cache::mergeContexts(['languages:language_interface', 'theme'], parent::getExpectedCacheContexts());
+    return parent::getExpectedCacheContexts();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET';
+        return "The 'access comments' permission is required and the comment must be published.";
+      case 'POST';
+        return "The 'post comments' permission is required.";
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests POSTing a comment without critical base fields.
+   *
+   * testPostIndividual() is testing with the most minimal normalization possible: the one
+   * returned by ::getNormalizedPostEntity().
+   *
+   * But Comment entities have some very special edge cases:
+   * - base fields that are not marked as required in
+   *   \Drupal\comment\Entity\Comment::baseFieldDefinitions() yet in fact are
+   *   required.
+   * - base fields that are marked as required, but yet can still result in
+   *   validation errors other than "missing required field".
+   */
+  public function testPostIndividualDxWithoutCriticalBaseFields() {
+    $this->setUpAuthorization('POST');
+
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName));
+    $request_options = $this->getAuthenticationRequestOptions('POST');
+
+    $remove_field = function(array $normalization, $type, $attribute_name) {
+      unset($normalization['data'][$type][$attribute_name]);
+      return $normalization;
+    };
+
+    // DX: 422 when missing 'entity_type' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getNormalizedPostEntity(), 'attributes',  'entity_type'));
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'Internal Server Error', 'The "" entity type does not exist.', $response);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'entity_type: This value should not be null.', $response);
+
+    // DX: 422 when missing 'entity_id' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getNormalizedPostEntity(), 'relationships', 'entity_id'));
+    // @todo Remove the try/catch in favor of the two commented lines in
+    // https://www.drupal.org/node/2820364.
+    try {
+      $response = $this->request('POST', $url, $request_options);
+      // This happens on DrupalCI.
+       $this->assertSame(500, $response->getStatusCode());
+    }
+    catch (\Exception $e) {
+      // This happens on local development environments
+      $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage());
+    }
+    // $response = $this->request('POST', $url, $request_options);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'entity_id: This value should not be null.', $response);
+
+    // DX: 422 when missing 'field_name' field.
+    $request_options[RequestOptions::BODY] = Json::encode($remove_field($this->getNormalizedPostEntity(), 'attributes', 'field_name'));
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+    $this->assertResourceErrorResponse(500, 'Internal Server Error', 'Field  is unknown.', $response);
+    // $this->assertResourceErrorResponse(422, 'Unprocessable Entity', 'field_name: This value should not be null.', $response);
+  }
+
+  /**
+   * Tests POSTing a comment with and without 'skip comment approval'
+   */
+  public function testPostIndividualSkipCommentApproval() {
+    $this->setUpAuthorization('POST');
+
+    // Create request.
+    $request_options = $this->getAuthenticationRequestOptions('POST');
+    $request_options[RequestOptions::BODY] = Json::encode($this->getNormalizedPostEntity());
+
+    $url = Url::fromRoute('jsonapi.comment--comment.collection');
+
+    // Status should be FALSE when posting as anonymous.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response, Cache::mergeTags(['http_response', 'comment:2'], []), $this->getExpectedCacheContexts());
+    $this->assertFalse(Json::decode((string) $response->getBody())['data']['attributes']['status']);
+    $this->assertFalse($this->entityStorage->loadUnchanged(2)->isPublished());
+
+    // Grant anonymous permission to skip comment approval.
+    $this->grantPermissionsToTestedRole(['skip comment approval']);
+
+    // Status should be TRUE when posting as anonymous and skip comment approval.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response, Cache::mergeTags(['http_response', 'comment:3'], []), $this->getExpectedCacheContexts());
+    $this->assertTrue(Json::decode((string) $response->getBody())['data']['attributes']['status']);
+    $this->assertTrue($this->entityStorage->loadUnchanged(3)->isPublished());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    // @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
+    return parent::getExpectedUnauthorizedAccessCacheability()
+      ->addCacheTags(['comment:1']);
+  }
+
+}
diff --git a/tests/src/Functional/ConfigTestTest.php b/tests/src/Functional/ConfigTestTest.php
new file mode 100644
index 0000000..e51419a
--- /dev/null
+++ b/tests/src/Functional/ConfigTestTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\config_test\Entity\ConfigTest;
+use Drupal\Core\Url;
+
+/**
+ * @group jsonapi
+ */
+class ConfigTestTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['config_test', 'config_test_rest'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'config_test';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'config_test--config_test';
+
+  /**
+   * @var \Drupal\config_test\ConfigTestInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['view config_test']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $config_test = ConfigTest::create([
+      'id' => 'llama',
+      'label' => 'Llama',
+    ]);
+    $config_test->save();
+
+    return $config_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/config_test/config_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'config_test--config_test',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'id' => 'llama',
+          'weight' => 0,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'label' => 'Llama',
+          'style' => NULL,
+          'size' => NULL,
+          'size_value' => NULL,
+          'protected_property' => NULL,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/tests/src/Functional/EntityTestTest.php b/tests/src/Functional/EntityTestTest.php
new file mode 100644
index 0000000..6268a8a
--- /dev/null
+++ b/tests/src/Functional/EntityTestTest.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * @group jsonapi
+ */
+class EntityTestTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'entity_test';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'entity_test--entity_test';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [];
+
+  /**
+   * @var \Drupal\entity_test\Entity\EntityTest
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view test entity']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities']);
+        break;
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer entity_test content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Set flag so that internal field 'internal_string_field' is created.
+    // @see entity_test_entity_base_field_info()
+    $this->container->get('state')->set('entity_test.internal_field', TRUE);
+    \Drupal::entityDefinitionUpdateManager()->applyUpdates();
+
+    $entity_test = EntityTest::create([
+      'name' => 'Llama',
+      'type' => 'entity_test',
+      // Set a value for the internal field to confirm that it will not be
+      // returned in normalization.
+      // @see entity_test_entity_base_field_info().
+      'internal_string_field' => [
+        'value' => 'This value shall not be internal!',
+      ],
+    ]);
+    $entity_test->setOwnerId(0);
+    $entity_test->save();
+
+    return $entity_test;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    $author = User::load(0);
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'entity_test--entity_test',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'id' => 1,
+          'created' => (int) $this->entity->get('created')->value,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'created' => $this->formatExpectedTimestampItemValues((int) $this->entity->get('created')->value),
+          'field_test_text' => NULL,
+          // @todo Remove this in https://www.drupal.org/project/jsonapi/issues/2921257.
+          'internal_string_field' => 'This value shall not be internal!',
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'type' => 'entity_test',
+          'uuid' => $this->entity->uuid(),
+        ],
+        'relationships' => [
+          'user_id' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/user_id',
+              'self' => $self_url . '/relationships/user_id',
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'type' => 'entity_test--entity_test',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'view test entity' permission is required.";
+      case 'POST':
+        return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+}
diff --git a/tests/src/Functional/NodeTest.php b/tests/src/Functional/NodeTest.php
new file mode 100644
index 0000000..42f43e2
--- /dev/null
+++ b/tests/src/Functional/NodeTest.php
@@ -0,0 +1,283 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * @group jsonapi
+ */
+class NodeTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'node';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'node--camelids';
+
+  /**
+   * @var \Drupal\node\NodeInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'revision_timestamp' => NULL,
+    // @todo This is a relationship, and cannot be tested in the same way. Fix in https://www.drupal.org/project/jsonapi/issues/2939810.
+//    'revision_uid' => NULL,
+    'created' => "The 'administer nodes' permission is required.",
+    'changed' => NULL,
+    'promote' => "The 'administer nodes' permission is required.",
+    'sticky' => "The 'administer nodes' permission is required.",
+    'path' => "The following permissions are required: 'create url aliases' OR 'administer url aliases'.",
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+        break;
+      case 'PATCH':
+        // Do not grant the 'create url aliases' permission to test the case
+        // when the path field is protected/not accessible, see
+        // \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase
+        // for a positive test.
+        $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    if (!NodeType::load('camelids')) {
+      // Create a "Camelids" node type.
+      NodeType::create([
+        'name' => 'Camelids',
+        'type' => 'camelids',
+      ])->save();
+    }
+
+    // Create a "Llama" node.
+    $node = Node::create(['type' => 'camelids']);
+    $node->setTitle('Llama')
+      ->setOwnerId($this->account->id())
+      ->setPublished(TRUE)
+      ->setCreatedTime(123456789)
+      ->setChangedTime(123456789)
+      ->setRevisionCreationTime(123456789)
+      ->set('path', '/llama')
+      ->save();
+
+    return $node;
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    $self_url = Url::fromUri('base:/jsonapi/node/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'node--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'created' => $this->formatExpectedTimestampItemValues(123456789),
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'nid' => 1,
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'promote' => TRUE,
+          'revision_default' => TRUE,
+          'revision_log' => NULL,
+          'revision_timestamp' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'revision_timestamp' => $this->formatExpectedTimestampItemValues(123456789),
+          // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518.
+          'revision_translation_affected' => TRUE,
+          'status' => TRUE,
+          'sticky' => FALSE,
+          'title' => 'Llama',
+          'uuid' => $this->entity->uuid(),
+          'vid' => 1,
+        ],
+        'relationships' => [
+          'type' => [
+            'data' => [
+              'id' => NodeType::load('camelids')->uuid(),
+              'type' => 'node_type--node_type',
+            ],
+            'links' => [
+              'related' => $self_url . '/type',
+              'self' => $self_url . '/relationships/type',
+            ],
+          ],
+          'uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user',
+            ],
+            'links' => [
+              'related' => $self_url . '/uid',
+              'self' => $self_url . '/relationships/uid',
+            ],
+          ],
+          'revision_uid' => [
+            'data' => [
+              'id' => $author->uuid(),
+              'type' => 'user--user'
+            ],
+            'links' => [
+              'related' => $self_url . '/revision_uid',
+              'self' => $self_url . '/relationships/revision_uid',
+            ]
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'type' => 'node--camelids',
+        'attributes' => [
+          'title' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+      case 'PATCH':
+      case 'DELETE':
+        return "The 'access content' permission is required.";
+      case 'POST':
+        // @see \Drupal\node\NodeAccessControlHandler::createAccess() forbids access without providing a reason if the user doe
+        return '';
+    }
+  }
+
+  /**
+   * Tests PATCHing a node's path with and without 'create url aliases'.
+   *
+   * For a positive test, see the similar test coverage for Term.
+   *
+   * @see \Drupal\Tests\jsonapi\Functional\TermTest::testPatchPath()
+   * @see \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase::testPatchPath()
+   */
+  public function testPatchPath() {
+    $this->setUpAuthorization('GET');
+    $this->setUpAuthorization('PATCH');
+
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+
+    // GET node's current normalization.
+    $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
+    $normalization = Json::decode((string) $response->getBody());
+
+    // Change node's path alias.
+    $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
+
+    // Create node PATCH request.
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // PATCH request: 403 when creating URL aliases unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to PATCH the selected field (path). The following permissions are required: 'create url aliases' OR 'administer url aliases'.",
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/node--camelids/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data/attributes/path',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+
+    // Grant permission to create URL aliases.
+    $this->grantPermissionsToTestedRole(['create url aliases']);
+
+    // Repeat PATCH request: 200.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939
+    $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'node:1'], ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site']);
+    $updated_normalization = Json::decode((string) $response->getBody());
+    $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
+  }
+
+}
diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000..4ead581
--- /dev/null
+++ b/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,1302 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Behat\Mink\Driver\BrowserKitDriver;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\Random;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityNullStorage;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
+use Drupal\Core\Url;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\path\Plugin\Field\FieldType\PathItem;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Subclass this for every JSON API resource type.
+ */
+abstract class ResourceTestBase extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['jsonapi', 'basic_auth'];
+
+  /**
+   * The tested entity type.
+   *
+   * @var string
+   */
+  protected static $entityTypeId = NULL;
+
+  /**
+   * The name of the tested JSON API resource type.
+   *
+   * @var string
+   */
+  protected static $resourceTypeName = NULL;
+
+  /**
+   * The fields that are protected against modification during PATCH requests.
+   *
+   * @var string[]
+   */
+  protected static $patchProtectedFieldNames;
+
+  /**
+   * The entity ID for the first created entity in testPost().
+   *
+   * The default value of 2 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $firstCreatedEntityId = 2;
+
+  /**
+   * The entity ID for the second created entity in testPost().
+   *
+   * The default value of 3 should work for most content entities.
+   *
+   * @see ::testPost()
+   *
+   * @var string|int
+   */
+  protected static $secondCreatedEntityId = 3;
+
+  /**
+   * Optionally specify which field is the 'label' field. Some entities specify
+   * a 'label_callback', but not a 'label' entity key. For example: User.
+   *
+   * @see ::getInvalidNormalizedEntityToCreate
+   *
+   * @var string|null
+   */
+  protected static $labelFieldName = NULL;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * The account to use for authentication.
+   *
+   * @var null|\Drupal\Core\Session\AccountInterface
+   */
+  protected $account;
+
+  /**
+   * The entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $entityStorage;
+
+  /**
+   * The serializer service.
+   *
+   * @var \Symfony\Component\Serializer\Serializer
+   */
+  protected $serializer;
+
+  /**
+   * The Entity-to-JSON-API service.
+   *
+   * @var \Drupal\jsonapi\EntityToJsonApi
+   */
+  protected $entityToJsonApi;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->serializer = $this->container->get('jsonapi.serializer_do_not_use_removal_imminent');
+    $this->entityToJsonApi = $this->container->get('jsonapi.entity.to_jsonapi');
+
+    // Ensure the anonymous user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::ANONYMOUS_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
+
+    // Ensure the authenticated user role has no permissions at all.
+    $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
+    foreach ($user_role->getPermissions() as $permission) {
+      $user_role->revokePermission($permission);
+    }
+    $user_role->save();
+    assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
+
+    // Create an account, which tests will use. Also ensure the @current_user
+    // service uses this account, to ensure the @jsonapi.entity.to_jsonapi
+    // service that we use to generate expectations matching that of this user.
+    $this->account = $this->createUser();
+    $this->container->get('current_user')->setAccount($this->account);
+
+    // Create an entity.
+    $this->entityStorage = $this->container->get('entity_type.manager')
+      ->getStorage(static::$entityTypeId);
+    $this->entity = $this->createEntity();
+    \Drupal::service('jsonapi.resource_type.repository')->clearCachedDefinitions();
+    \Drupal::service('router.builder')->rebuild();
+
+    /*
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Add access-protected field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'type' => 'text',
+      ])
+        ->setCardinality(1)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      // Add multi-value field.
+      FieldStorageConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test_multivalue',
+        'type' => 'string',
+      ])
+        ->setCardinality(3)
+        ->save();
+      FieldConfig::create([
+        'entity_type' => static::$entityTypeId,
+        'field_name' => 'field_rest_test_multivalue',
+        'bundle' => $this->entity->bundle(),
+      ])
+        ->setLabel('Test field: multi-value')
+        ->setTranslatable(FALSE)
+        ->save();
+
+      // Reload entity so that it has the new field.
+      $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+      // Some entity types are not stored, hence they cannot be reloaded.
+      if ($reloaded_entity !== NULL) {
+        $this->entity = $reloaded_entity;
+
+        // Set a default value on the fields.
+        $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+        $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
+        $this->entity->save();
+      }
+    }
+    */
+  }
+
+  /**
+   * Creates the entity to be tested.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity to be tested.
+   */
+  abstract protected function createEntity();
+
+  /**
+   * Returns the expected normalization of the entity.
+   *
+   * @see ::createEntity()
+   *
+   * @return array
+   */
+  abstract protected function getExpectedNormalizedEntity();
+
+  /**
+   * Returns the normalized POST entity.
+   *
+   * @see ::testPost
+   *
+   * @return array
+   */
+  abstract protected function getNormalizedPostEntity();
+
+  /**
+   * Returns the normalized PATCH entity.
+   *
+   * By default, reuses ::getNormalizedPostEntity(), which works fine for most
+   * entity types. A counterexample: the 'comment' entity type.
+   *
+   * @see ::testPatch
+   *
+   * @return array
+   */
+  protected function getNormalizedPatchEntity() {
+    return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getNormalizedPostEntity());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessCacheability() {
+    return (new CacheableMetadata())
+      ->setCacheTags(['4xx-response', 'http_response'])
+      ->setCacheContexts(['user.permissions']);
+  }
+
+  /**
+   * The expected cache tags for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheTags() {
+    $expected_cache_tags = [
+      'http_response',
+    ];
+    return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
+  }
+
+  /**
+   * The expected cache contexts for the GET/HEAD response of the test entity.
+   *
+   * @see ::testGet
+   *
+   * @return string[]
+   */
+  protected function getExpectedCacheContexts() {
+    return [
+      // Cache contexts for JSON API URL query parameters.
+      'url.query_args:fields',
+      'url.query_args:filter',
+      'url.query_args:include',
+      'url.query_args:page',
+      'url.query_args:sort',
+      // Drupal defaults.
+      'url.site',
+      // @todo Figure out why this is no longer among the response's cache contexts.
+//      'user.permissions',
+    ];
+  }
+
+  /**
+   * Sets up the necessary authorization.
+   *
+   * In case of a test verifying publicly accessible REST resources: grant
+   * permissions to the anonymous user role.
+   *
+   * In case of a test verifying behavior when using a particular authentication
+   * provider: create a user with a particular set of permissions.
+   *
+   * Because of the $method parameter, it's possible to first set up
+   * authentication for only GET, then add POST, et cetera. This then also
+   * allows for verifying a 403 in case of missing authorization.
+   *
+   * @param string $method
+   *   The HTTP method for which to set up authentication.
+   *
+   * @see ::grantPermissionsToAnonymousRole()
+   * @see ::grantPermissionsToAuthenticatedRole()
+   */
+  abstract protected function setUpAuthorization($method);
+
+  /**
+   * Return the expected error message.
+   *
+   * @param string $method
+   *   The HTTP method (GET, POST, PATCH, DELETE).
+   *
+   * @return string
+   *   The error string.
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    $permission = $this->entity->getEntityType()->getAdminPermission();
+    if ($permission !== FALSE) {
+      return "The '{$permission}' permission is required.";
+    }
+
+    return NULL;
+  }
+
+  /**
+   * Grants permissions to the authenticated role.
+   *
+   * @param string[] $permissions
+   *   Permissions to grant.
+   */
+  protected function grantPermissionsToTestedRole(array $permissions) {
+    $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
+  }
+
+  /**
+   * Performs a HTTP request. Wraps the Guzzle HTTP client.
+   *
+   * Why wrap the Guzzle HTTP client? Because we want to keep the actual test
+   * code as simple as possible, and hence not require them to specify the
+   * 'http_errors = FALSE' request option, nor do we want them to have to
+   * convert Drupal Url objects to strings.
+   *
+   * We also don't want to follow redirects automatically, to ensure these tests
+   * are able to detect when redirects are added or removed.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   *
+   * @param string $method
+   *   HTTP method.
+   * @param \Drupal\Core\Url $url
+   *   URL to request.
+   * @param array $request_options
+   *   Request options to apply.
+   *
+   * @return \Psr\Http\Message\ResponseInterface
+   */
+  protected function request($method, Url $url, array $request_options) {
+    $request_options[RequestOptions::HTTP_ERRORS] = FALSE;
+    $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
+    $request_options = $this->decorateWithXdebugCookie($request_options);
+    $client = $this->getSession()->getDriver()->getClient()->getClient();
+    return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
+  }
+
+  /**
+   * Asserts that a resource response has the given status code and body.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string|false $expected_body
+   *   The expected response body. FALSE in case this should not be asserted.
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The response to assert.
+   * @param string[]|false $expected_cache_tags
+   *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
+   *   header, or FALSE if that header should be absent. Defaults to FALSE.
+   * @param string[]|false $expected_cache_contexts
+   *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
+   *   response header, or FALSE if that header should be absent. Defaults to
+   *   FALSE.
+   * @param string|false $expected_page_cache_header_value
+   *   (optional) The expected X-Drupal-Cache response header value, or FALSE if
+   *   that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
+   *   to FALSE.
+   * @param string|false $expected_dynamic_page_cache_header_value
+   *   (optional) The expected X-Drupal-Dynamic-Cache response header value, or
+   *   FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
+   *   Defaults to FALSE.
+   */
+  protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
+    $this->assertSame($expected_status_code, $response->getStatusCode());
+    if ($expected_status_code === 204) {
+      // DELETE responses should not include a Content-Type header. But Apache
+      // sets it to 'text/html' by default. We also cannot detect the presence
+      // of Apache either here in the CLI. For now having this documented here
+      // is all we can do.
+      // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+      $this->assertSame('', (string) $response->getBody());
+    }
+    else {
+      $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type'));
+      if ($expected_body !== FALSE) {
+        $this->assertSame($expected_body, (string) $response->getBody());
+      }
+    }
+
+    // Expected cache tags: X-Drupal-Cache-Tags header.
+    $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
+    if (is_array($expected_cache_tags)) {
+      $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
+    }
+
+    // Expected cache contexts: X-Drupal-Cache-Contexts header.
+    $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
+    if (is_array($expected_cache_contexts)) {
+      $this->assertSame($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
+    }
+
+    // Expected Page Cache header value: X-Drupal-Cache header.
+    if ($expected_page_cache_header_value !== FALSE) {
+      $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
+      $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    }
+
+    // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
+    if ($expected_dynamic_page_cache_header_value !== FALSE) {
+      $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
+      $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
+    }
+    else {
+      $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
+    }
+  }
+
+  /**
+   * Asserts that a resource error response has the given message.
+   *
+   * @param int $expected_status_code
+   *   The expected response status.
+   * @param string $expected_title.
+   *  …………………………………………………………………………………………………………………………………………………………………………………………………………………
+   * @param string $expected_message
+   *   …………………………………………………………………………………………………………………………………………………………………………………………………………………
+   * @param \Psr\Http\Message\ResponseInterface $response
+   *   The error response to assert.
+   * @param string[]|false $expected_cache_tags
+   *   (optional) The expected cache tags in the X-Drupal-Cache-Tags response
+   *   header, or FALSE if that header should be absent. Defaults to FALSE.
+   * @param string[]|false $expected_cache_contexts
+   *   (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
+   *   response header, or FALSE if that header should be absent. Defaults to
+   *   FALSE.
+   * @param string|false $expected_page_cache_header_value
+   *   (optional) The expected X-Drupal-Cache response header value, or FALSE if
+   *   that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
+   *   to FALSE.
+   * @param string|false $expected_dynamic_page_cache_header_value
+   *   (optional) The expected X-Drupal-Dynamic-Cache response header value, or
+   *   FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
+   *   Defaults to FALSE.
+   */
+  protected function assertResourceErrorResponse($expected_status_code, $expected_title, $expected_message, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) {
+    $expected = [
+      'errors' => [
+        [
+          'title' => $expected_title,
+          'status' => $expected_status_code,
+          'detail' => $expected_message,
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl($expected_status_code),
+          ],
+          'code' => 0,
+        ],
+      ],
+    ];
+    // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer::buildErrorObjects()
+    // @see https://www.drupal.org/project/jsonapi/issues/2832211#comment-11826234, point 4
+    if ($expected_status_code === 422) {
+      unset($expected['errors'][0]['links']);
+    }
+    $expected_body = ($expected_message !== FALSE) ? $this->serializer->encode($expected, 'api_json') : FALSE;
+    // @todo ………………………………………;;
+    $this->assertResourceResponse($expected_status_code, $expected_body, $response);
+//    $this->assertResourceResponse($expected_status_code, $expected_body, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value);
+  }
+
+  /**
+   * Adds the Xdebug cookie to the request options.
+   *
+   * @param array $request_options
+   *   The request options.
+   *
+   * @return array
+   *   Request options updated with the Xdebug cookie if present.
+   */
+  protected function decorateWithXdebugCookie(array $request_options) {
+    $session = $this->getSession();
+    $driver = $session->getDriver();
+    if ($driver instanceof BrowserKitDriver) {
+      $client = $driver->getClient();
+      foreach ($client->getCookieJar()->all() as $cookie) {
+        if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) {
+          $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue();
+        }
+        else {
+          $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue();
+        }
+      }
+    }
+    return $request_options;
+  }
+
+  protected function makeNormalizationViolateJsonApiSpec(array $normalization, $key) {
+    unset($normalization['data'][$key]);
+    return $normalization;
+  }
+
+  /**
+   * Makes the given entity normalization invalid.
+   *
+   * @param array $normalization
+   *   An entity normalization.
+   *
+   * @return array
+   *   The updated entity normalization, now invalid.
+   */
+  protected function makeNormalizationInvalid(array $normalization) {
+    // Add a second label to this entity to make it invalid.
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $normalization['data']['attributes'][$label_field] = [
+      0 => $normalization['data']['attributes'][$label_field],
+      1 => 'Second Title',
+    ];
+
+    return $normalization;
+  }
+
+  /**
+   * Tests GETting an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testGetIndividual() {
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('GET');
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('GET', $url, $request_options);
+    $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
+    $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to GET the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    // @todo This is
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+//    $this->assertResourceResponse(403, Json::encode($expected), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS');
+    $this->assertArrayNotHasKey('Link', $response->getHeaders());
+
+    $this->setUpAuthorization('GET');
+
+    // 200 for well-formed HEAD request.
+    $response = $this->request('HEAD', $url, $request_options);
+    $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'MISS');
+    $head_headers = $response->getHeaders();
+
+    // 200 for well-formed GET request. Page Cache hit because of HEAD request.
+    // Same for Dynamic Page Cache hit.
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'HIT');
+    // Assert that Dynamic Page Cache did not store a ResourceResponse object,
+    // which needs serialization after every cache hit. Instead, it should
+    // contain a flattened response. Otherwise performance suffers.
+    // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
+    $cache_items = $this->container->get('database')
+      ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
+        ':pattern' => '%[route]=jsonapi.%',
+      ])
+      ->fetchAllAssoc('cid');
+    $this->assertTrue(count($cache_items) >= 2);
+    $found_cache_redirect = FALSE;
+    $found_cached_200_response = FALSE;
+    $other_cached_responses_are_4xx = TRUE;
+    foreach ($cache_items as $cid => $cache_item) {
+      $cached_data = unserialize($cache_item->data);
+      if (!isset($cached_data['#cache_redirect'])) {
+        $cached_response = $cached_data['#response'];
+        if ($cached_response->getStatusCode() === 200) {
+          $found_cached_200_response = TRUE;
+        }
+        elseif (!$cached_response->isClientError()) {
+          $other_cached_responses_are_4xx = FALSE;
+        }
+        $this->assertNotInstanceOf(ResourceResponse::class, $cached_response);
+        $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
+      }
+      else {
+        $found_cache_redirect = TRUE;
+      }
+    }
+    $this->assertTrue($found_cache_redirect);
+    $this->assertTrue($found_cached_200_response);
+    $this->assertTrue($other_cached_responses_are_4xx);
+
+    // Sort the serialization data first so we can do an identical comparison
+    // for the keys with the array order the same (it needs to match with
+    // identical comparison).
+    $expected = $this->getExpectedNormalizedEntity();
+    static::recursiveKSort($expected);
+    $actual = $this->serializer->decode((string) $response->getBody(), 'api_json');
+    static::recursiveKSort($actual);
+    $this->assertSame($expected, $actual);
+
+    // 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), 'api_json', [
+      'target_entity' => static::$entityTypeId,
+      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+    ]);
+    // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2942561#comment-12472704.
+//    $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+    $get_headers = $response->getHeaders();
+
+    // Verify that the GET and HEAD responses are the same. The only difference
+    // is that there's no body. For this reason the 'Transfer-Encoding' and
+    // 'Vary' headers are also added to the list of headers to ignore, as they
+    // may be added to GET requests, depending on web server configuration. They
+    // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
+    $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary'];
+    $header_cleaner = function ($headers) use ($ignored_headers) {
+      foreach ($headers as $header => $value) {
+        if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
+          unset($headers[$header]);
+        }
+      }
+      return $headers;
+    };
+    $get_headers = $header_cleaner($get_headers);
+    $head_headers = $header_cleaner($head_headers);
+    $this->assertSame($get_headers, $head_headers);
+
+    // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932.
+    /*
+    // BC: serialization_update_8401().
+    // Only run this for fieldable entities. It doesn't make sense for config
+    // entities as config values always use the raw values (as per the config
+    // schema), returned directly from the ConfigEntityNormalizer, which
+    // doesn't deal with fields individually.
+    if ($this->entity instanceof FieldableEntityInterface) {
+      // Test the BC settings for timestamp values.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
+      // Rebuild the container so new config is reflected in the addition of the
+      // TimestampItemNormalizer.
+      $this->rebuildAll();
+
+      $response = $this->request('GET', $url, $request_options);
+      $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
+
+      // This ensures the BC layer for bc_timestamp_normalizer_unix works as
+      // expected. This method should be using
+      // ::formatExpectedTimestampValue() to generate the timestamp value. This
+      // will take into account the above config setting.
+      $expected = $this->getExpectedNormalizedEntity();
+      // Config entities are not affected.
+      // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
+      static::recursiveKSort($expected);
+      $actual = $this->serializer->decode((string) $response->getBody(), 'api_json');
+      static::recursiveKSort($actual);
+      $this->assertSame($expected, $actual);
+
+      // Reset the config value and rebuild.
+      $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
+      $this->rebuildAll();
+    }
+    */
+
+    // DX: 404 when GETting non-existing entity, but HTML response.
+    $random_uuid = \Drupal::service('uuid')->generate();
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $random_uuid]);
+    $response = $this->request('GET', $url, $request_options);
+    $this->assertSame(404, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+    // DX: 404 JSON API response if the ?_format query string is present.
+    $url->setOption('query', ['_format' => 'api_json']);
+    $response = $this->request('GET', $url, $request_options);
+    $path = str_replace($random_uuid, '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+    $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
+    $this->assertResourceErrorResponse(404, 'Not Found', $message, $response);
+  }
+
+  /**
+   * Tests POSTing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testPostIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
+      return;
+    }
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json');
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json');
+    $parseable_invalid_request_body_missing_type = $this->serializer->encode($this->makeNormalizationViolateJsonApiSpec($this->getNormalizedPostEntity(), 'type'), 'api_json');
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), 'api_json');
+    $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getNormalizedPostEntity()));
+    // @todo …
+//    $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    $request_options = $this->getAuthenticationRequestOptions('POST');
+
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943170.
+    /*
+    // DX: 415 when no Content-Type request header. HTML response because
+    // missing ?_format query string.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => 'api_json']);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, '…', 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = '';
+
+    // DX: 400 when no request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+*/
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('POST', $url, $request_options);
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943165.
+    // $this->assertResourceErrorResponse(400, 'Bad Request', 'Syntax error', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('POST', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('POST');
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          // @todo Why is the reason missing here?
+          'detail' => "The current user is not allowed to POST the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+
+    $this->setUpAuthorization('POST');
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type;
+
+    // DX: 400 when invalid JSON API request body.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'Bad Request', 'Resource object must include a "type".', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('POST', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/' . $label_field,
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, json_encode($expected), $response);
+//    $this->assertResourceErrorResponse(422, 'Unprocessable Entity', "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934386 lands.
+    // DX: 403 when invalid entity: UUID field too long.
+    // @todo Fix this in https://www.drupal.org/node/2149851.
+    if ($this->entity->getEntityType()->hasKey('uuid')) {
+      $response = $this->request('POST', $url, $request_options);
+      $expected = [
+        'errors' => [
+          [
+            'title' => 'Forbidden',
+            'status' => 403,
+            'detail' => "IDs should be properly generated and formatted UUIDs as described in RFC 4122.",
+            'links' => [
+              'info' => HttpExceptionNormalizer::getInfoUrl(403),
+            ],
+            'code' => 0,
+            'source' => [
+              'pointer' => '/data/id',
+            ],
+          ],
+        ],
+      ];
+      $this->assertResourceResponse(403, Json::encode($expected), $response);
+    }
+//
+//    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+//
+//    // DX: 403 when entity contains field without 'edit' access.
+//    $response = $this->request('POST', $url, $request_options);
+//    $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands.
+    /*
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
+    */
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // 201 for well-formed request.
+    $response = $this->request('POST', $url, $request_options);
+    $this->assertResourceResponse(201, FALSE, $response, Cache::mergeTags(['http_response', static::$entityTypeId . ':' . static::$firstCreatedEntityId], []), $this->getExpectedCacheContexts());
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entityStorage->load(static::$firstCreatedEntityId)->uuid()])->setAbsolute(TRUE)->toString();
+    //$location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString();
+    $this->assertSame([$location], $response->getHeader('Location'));
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // If the entity is stored, perform extra checks.
+    if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
+      // Assert that the entity was indeed created, and that the response body
+      // contains the serialized created entity.
+      $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
+      $created_entity_normalization = $this->entityToJsonApi->normalize($created_entity);
+      // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
+      // its body unconditionally.
+      if (static::$entityTypeId !== 'taxonomy_term') {
+        $decoded_response_body = $this->serializer->decode((string) $response->getBody(), 'api_json');
+        // @todo Remove the two lines below once https://www.drupal.org/project/jsonapi/issues/2925043 lands.
+        unset($created_entity_normalization['links']);
+        unset($decoded_response_body['links']);
+        $this->assertSame($created_entity_normalization, $decoded_response_body);
+      }
+      // Assert that the entity was indeed created using the POSTed values.
+      foreach ($this->getNormalizedPostEntity()['data']['attributes'] as $field_name => $field_normalization) {
+        $this->assertSame($field_normalization, $created_entity_normalization['data']['attributes'][$field_name]);
+      }
+      if (isset($this->getNormalizedPostEntity()['data']['relationships'])) {
+        foreach ($this->getNormalizedPostEntity()['data']['relationships'] as $field_name => $relationship_field_normalization) {
+          // When POSTing relationships: 'data' is required, 'links' is optional.
+          $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_normalization['data']['relationships'][$field_name], ['links' => TRUE]));
+        }
+      }
+    }
+  }
+
+
+  /**
+   * Tests PATCHing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testPatchIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
+      return;
+    }
+
+    // Try with all of the following request bodies.
+    $unparseable_request_body = '!{>}<';
+    $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json');
+    $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json');
+    $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), 'api_json');
+//    $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json');
+    // The 'field_rest_test' field does not allow 'view' access, so does not end
+    // up in the normalization. Even when we explicitly add it the normalization
+    // that we send in the body of a PATCH request, it is considered invalid.
+//    $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], 'api_json');
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');;
+
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943170.
+    /*
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertSame(415, $response->getStatusCode());
+    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertContains('A client error happened', (string) $response->getBody());
+
+    $url->setOption('query', ['_format' => static::$format]);
+
+    // DX: 415 when no Content-Type request header.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+    // DX: 400 when no request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+*/
+
+    $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+    // DX: 400 when unparseable request body.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2943165.
+    // $this->assertResourceErrorResponse(400, 'Bad request', 'Syntax error', $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('PATCH', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH');
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to PATCH the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+
+    $this->setUpAuthorization('PATCH');
+
+    // DX: 422 when invalid entity: multiple values sent for single-value field.
+    $response = $this->request('PATCH', $url, $request_options);
+    $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
+    $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.",
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/' . $label_field,
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, json_encode($expected), $response);
+
+/*
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+    // DX: 403 when entity contains field without 'edit' access.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
+
+    $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
+
+    // DX: 403 when entity contains field without 'edit' nor 'view' access, even
+    // when the value for that field matches the current value. This is allowed
+    // in principle, but leads to information disclosure.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
+*/
+
+    // DX: 403 when sending PATCH request with updated read-only fields.
+    list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
+    // Send PATCH request by serializing the modified entity, assert the error
+    // response, change the modified entity field that caused the error response
+    // back to its original value, repeat.
+    foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
+      $request_options[RequestOptions::BODY] = $this->entityToJsonApi->serialize($modified_entity);
+      $response = $this->request('PATCH', $url, $request_options);
+      $expected = [
+        'errors' => [
+          [
+            'title' => 'Forbidden',
+            'status' => 403,
+            'detail' => "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''),
+            'links' => [
+              'info' => HttpExceptionNormalizer::getInfoUrl(403),
+            ],
+            'code' => 0,
+            'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+            'source' => [
+              'pointer' => '/data/attributes/' . $patch_protected_field_name,
+            ],
+          ],
+        ],
+      ];
+      $this->assertResourceResponse(403, Json::encode($expected), $response);
+      $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
+    }
+
+    // 200 for well-formed PATCH request that sends all fields (even including
+    // read-only ones, but with unchanged values).
+    $valid_request_body = NestedArray::mergeDeep($this->entityToJsonApi->normalize($this->entity), $this->getNormalizedPatchEntity());
+    // @todo Remove this foreach in https://www.drupal.org/project/jsonapi/issues/2939810.
+    foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
+      unset($valid_request_body['data']['attributes'][$field_name]);
+    }
+    $request_options[RequestOptions::BODY] = json_encode($valid_request_body);
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939
+    $this->assertResourceResponse(200, FALSE, $response, Cache::mergeTags(['http_response', $this->entity->getCacheTags()[0]], []), ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site']);
+
+    $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+    // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands.
+    /*
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
+
+    // DX: 415 when request body in existing but not allowed format.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
+    */
+
+    $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json';
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, Cache::mergeTags(['http_response', $this->entity->getCacheTags()[0]], []), ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site']);
+    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    // Assert that the entity was indeed updated, and that the response body
+    // contains the serialized updated entity.
+    $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
+    $updated_entity_normalization = $this->entityToJsonApi->normalize($updated_entity);
+    $this->assertSame($updated_entity_normalization, Json::decode((string) $response->getBody()));
+    // Assert that the entity was indeed created using the PATCHed values.
+    foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) {
+      // Some top-level keys in the normalization may not be fields on the
+      // entity (for example '_links' and '_embedded' in the HAL normalization).
+      if ($updated_entity->hasField($field_name)) {
+        // Subset, not same, because we can e.g. send just the target_id for the
+        // bundle in a PATCH request; the response will include more properties.
+        $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
+      }
+    }
+/*
+    // Ensure that fields do not get deleted if they're not present in the PATCH
+    // request. Test this using the configurable field that we added, but which
+    // is not sent in the PATCH request.
+    $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
+
+    // Multi-value field: remove item 0. Then item 1 becomes item 0.
+    $normalization_multi_value_tests = $this->getNormalizedPatchEntity();
+    $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
+    $normalization_remove_item = $normalization_multi_value_tests;
+    unset($normalization_remove_item['field_rest_test_multivalue'][0]);
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
+
+    // Multi-value field: add one item before the existing one, and one after.
+    $normalization_add_items = $normalization_multi_value_tests;
+    $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three'];
+    $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format);
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response);
+    $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
+*/
+  }
+
+  /**
+   * Tests DELETEing an individual resource, plus edge cases to ensure good DX.
+   */
+  public function testDeleteIndividual() {
+    // @todo Remove this in https://www.drupal.org/node/2300677.
+    if ($this->entity instanceof ConfigEntityInterface) {
+      $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
+      return;
+    }
+
+    // The URL and Guzzle request options that will be used in this test. The
+    // request options will be modified/expanded throughout this test:
+    // - to first test all mistakes a developer might make, and assert that the
+    //   error responses provide a good DX
+    // - to eventually result in a well-formed request that succeeds.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+
+    // DX: 403 when unauthorized.
+    $response = $this->request('DELETE', $url, $request_options);
+    $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE');
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => "The current user is not allowed to DELETE the selected resource." . (strlen($reason) ? ' ' . $reason : ''),
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(),
+          'source' => [
+            'pointer' => '/data',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+
+    $this->setUpAuthorization('DELETE');
+
+    // 204 for well-formed request.
+    $response = $this->request('DELETE', $url, $request_options);
+    // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939
+    $this->assertResourceResponse(204, '', $response, ['http_response'], ['']);
+  }
+
+  /**
+   * Transforms a normalization: casts all non-string types to strings.
+   *
+   * @param array $normalization
+   *   A normalization to transform.
+   *
+   * @return array
+   *   The transformed normalization.
+   */
+  protected static function castToString(array $normalization) {
+    foreach ($normalization as $key => $value) {
+      if (is_bool($value)) {
+        $normalization[$key] = (string) (int) $value;
+      }
+      elseif (is_int($value) || is_float($value)) {
+        $normalization[$key] = (string) $value;
+      }
+      elseif (is_array($value)) {
+        $normalization[$key] = static::castToString($value);
+      }
+    }
+    return $normalization;
+  }
+
+  /**
+   * Recursively sorts an array by key.
+   *
+   * @param array $array
+   *   An array to sort.
+   *
+   * @return array
+   *   The sorted array.
+   */
+  protected static function recursiveKSort(array &$array) {
+    // First, sort the main array.
+    ksort($array);
+
+    // Then check for child arrays.
+    foreach ($array as $key => &$value) {
+      if (is_array($value)) {
+        static::recursiveKSort($value);
+      }
+    }
+  }
+
+  /**
+   * Returns Guzzle request options for authentication.
+   *
+   * @param string $method
+   *   The HTTP method for this authenticated request.
+   *
+   * @return array
+   *   Guzzle request options to use for authentication.
+   *
+   * @see \GuzzleHttp\ClientInterface::request()
+   */
+  protected function getAuthenticationRequestOptions($method) {
+    return [
+      'headers' => [
+        'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+      ],
+    ];
+  }
+
+  /**
+   * Clones the given entity and modifies all PATCH-protected fields.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being tested and to modify.
+   *
+   * @return array
+   *   Contains two items:
+   *   1. The modified entity object.
+   *   2. The original field values, keyed by field name.
+   *
+   * @internal
+   */
+  protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
+    $modified_entity = clone $entity;
+    $original_values = [];
+    foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
+      $field = $modified_entity->get($field_name);
+      $original_values[$field_name] = $field->getValue();
+      switch ($field->getItemDefinition()->getClass()) {
+        case EntityReferenceItem::class:
+          // EntityReferenceItem::generateSampleValue() picks one of the last 50
+          // entities of the supported type & bundle. We don't care if the value
+          // is valid, we only care that it's different.
+          $field->setValue(['target_id' => 99999]);
+          break;
+        case BooleanItem::class:
+          // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
+          // chance of not picking a different value.
+          $field->value = ((int) $field->value) === 1 ? '0' : '1';
+          break;
+        case PathItem::class:
+          // PathItem::generateSampleValue() doesn't set a PID, which causes
+          // PathItem::postSave() to fail. Keep the PID (and other properties),
+          // just modify the alias.
+          $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
+          break;
+        default:
+          $original_field = clone $field;
+          while ($field->equals($original_field)) {
+            $field->generateSampleItems();
+          }
+          break;
+      }
+    }
+
+    return [$modified_entity, $original_values];
+  }
+
+}
\ No newline at end of file
diff --git a/tests/src/Functional/RoleTest.php b/tests/src/Functional/RoleTest.php
new file mode 100644
index 0000000..f40fae0
--- /dev/null
+++ b/tests/src/Functional/RoleTest.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\user\Entity\Role;
+
+/**
+ * @group jsonapi
+ */
+class RoleTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user_role';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'user_role--user_role';
+
+  /**
+   * @var \Drupal\user\RoleInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer permissions']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $role = Role::create([
+      'id' => 'llama',
+      'name' => $this->randomString(),
+    ]);
+    $role->save();
+
+    return $role;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/user_role/user_role/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user_role--user_role',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'weight' => 2,
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'id' => 'llama',
+          'label' => NULL,
+          'is_admin' => NULL,
+          'permissions' => [],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+}
diff --git a/tests/src/Functional/TermTest.php b/tests/src/Functional/TermTest.php
new file mode 100644
index 0000000..a36cfa9
--- /dev/null
+++ b/tests/src/Functional/TermTest.php
@@ -0,0 +1,366 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * @group jsonapi
+ */
+class TermTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy', 'path'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_term';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'taxonomy_term--camelids';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access content']);
+        break;
+
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create terms in camelids']);
+        break;
+
+      case 'PATCH':
+        // Grant the 'create url aliases' permission to test the case when
+        // the path field is accessible, see
+        // \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase
+        // for a negative test.
+        $this->grantPermissionsToTestedRole(['edit terms in camelids', 'create url aliases']);
+        break;
+
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['delete terms in camelids']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::load('camelids');
+    if (!$vocabulary) {
+      // Create a "Camelids" vocabulary.
+      $vocabulary = Vocabulary::create([
+        'name' => 'Camelids',
+        'vid' => 'camelids',
+      ]);
+      $vocabulary->save();
+    }
+
+    // Create a "Llama" taxonomy term.
+    $term = Term::create(['vid' => $vocabulary->id()])
+      ->setName('Llama')
+      ->setDescription("It is a little known fact that llamas cannot count higher than seven.")
+      ->setChangedTime(123456789)
+      ->set('path', '/llama');
+    $term->save();
+
+    return $term;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/taxonomy_term/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+
+    // We test with multiple parent terms, and combinations thereof.
+    // @see ::createEntity()
+    // @see ::testGetIndividual()
+    // @see ::testGetIndividualTermWithParent()
+    // @see ::providerTestGetIndividualTermWithParent()
+    $parent_term_ids = [];
+    for ($i = 0; $i < $this->entity->get('parent')->count(); $i++) {
+      $parent_term_ids[$i] = (int) $this->entity->get('parent')[$i]->target_id;
+    }
+
+    $expected_parent_normalization = FALSE;
+    switch ($parent_term_ids) {
+      case [0]:
+        // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339
+        $expected_parent_normalization = [
+          'data' => [],
+        ];
+        break;
+      case [2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+      case [0, 2]:
+        $expected_parent_normalization = [
+          'data' => [
+            // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+      case [3, 2]:
+        $expected_parent_normalization = [
+          'data' => [
+            [
+              'id' => Term::load(3)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+            [
+              'id' => Term::load(2)->uuid(),
+              'type' => 'taxonomy_term--camelids',
+            ],
+          ],
+          'links' => [
+            'related' => $self_url . '/parent',
+            'self' => $self_url . '/relationships/parent',
+          ],
+        ];
+        break;
+    }
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_term--camelids',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'default_langcode' => TRUE,
+          'description' => [
+            'value' => 'It is a little known fact that llamas cannot count higher than seven.',
+            'format' => NULL,
+            // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2921257.
+//            'processed' => "<p>It is a little known fact that llamas cannot count higher than seven.</p>\n",
+          ],
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'path' => [
+            'alias' => '/llama',
+            'pid' => 1,
+            'langcode' => 'en',
+          ],
+          'tid' => 1,
+          'uuid' => $this->entity->uuid(),
+          'weight' => 0,
+        ],
+        'relationships' => [
+          'parent' => $expected_parent_normalization,
+          'vid' => [
+            'data' => [
+              'id' => Vocabulary::load('camelids')->uuid(),
+              'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+            ],
+            'links' => [
+              'related' => $self_url . '/vid',
+              'self' => $self_url . '/relationships/vid',
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'type' => 'taxonomy_term--camelids',
+        'attributes' => [
+          'name' => 'Dramallama',
+          'description' => [
+            'value' => 'Dramallamas are the coolest camelids.',
+            'format' => NULL,
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access content' permission is required.";
+      case 'POST':
+        return "The following permissions are required: 'create terms in camelids' OR 'administer taxonomy'.";
+      case 'PATCH':
+        return "The following permissions are required: 'edit terms in camelids' OR 'administer taxonomy'.";
+      case 'DELETE':
+        return "The following permissions are required: 'delete terms in camelids' OR 'administer taxonomy'.";
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests PATCHing a term's path.
+   *
+   * For a negative test, see the similar test coverage for Node.
+   *
+   * @see \Drupal\Tests\jsonapi\Functional\NodeTest::testPatchPath()
+   * @see \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase::testPatchPath()
+   */
+  public function testPatchPath() {
+    $this->setUpAuthorization('GET');
+    $this->setUpAuthorization('PATCH');
+
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+
+    // GET term's current normalization.
+    $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET'));
+    $normalization = Json::decode((string) $response->getBody());
+
+    // Change term's path alias.
+    $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world';
+
+    // Create term PATCH request.
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // PATCH request: 200.
+    $response = $this->request('PATCH', $url, $request_options);
+    // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939
+    $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'taxonomy_term:1'], ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site']);
+    $updated_normalization = Json::decode((string) $response->getBody());
+    $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheTags() {
+    // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342.
+//    return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:filter.format.plain_text', 'config:filter.settings']);
+    return parent::getExpectedCacheTags();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedCacheContexts() {
+    // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342.
+//    return Cache::mergeContexts(['url.site'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
+    return parent::getExpectedCacheContexts();
+  }
+
+  /**
+   * Tests GETting a term with a parent term other than the default <root> (0).
+   *
+   * @see ::getExpectedNormalizedEntity()
+   *
+   * @dataProvider providerTestGetIndividualTermWithParent
+   */
+  public function testGetIndividualTermWithParent(array $parent_term_ids) {
+    // Create all possible parent terms.
+    Term::create(['vid' => Vocabulary::load('camelids')->id()])
+      ->setName('Lamoids')
+      ->save();
+    Term::create(['vid' => Vocabulary::load('camelids')->id()])
+      ->setName('Wimoids')
+      ->save();
+
+    // Modify the entity under test to use the provided parent terms.
+    $this->entity->set('parent', $parent_term_ids)->save();
+
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]);
+    //$url = $this->entity->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('GET');
+    $this->setUpAuthorization('GET');
+    $response = $this->request('GET', $url, $request_options);
+    $expected = $this->getExpectedNormalizedEntity();
+    static::recursiveKSort($expected);
+    $actual = Json::decode((string) $response->getBody());
+    static::recursiveKSort($actual);
+    $this->assertSame($expected, $actual);
+  }
+
+  public function providerTestGetIndividualTermWithParent() {
+    return [
+      'root parent: [0] (= no parent)' => [
+        [0]
+      ],
+      'non-root parent: [2]' => [
+        [2]
+      ],
+      'multiple parents: [0,2] (root + non-root parent)' => [
+        [0, 2]
+      ],
+      'multiple parents: [3,2] (both non-root parents)' => [
+        [3, 2]
+      ],
+    ];
+  }
+
+}
diff --git a/tests/src/Functional/UserTest.php b/tests/src/Functional/UserTest.php
new file mode 100644
index 0000000..2bbf956
--- /dev/null
+++ b/tests/src/Functional/UserTest.php
@@ -0,0 +1,359 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\user\Entity\User;
+use GuzzleHttp\RequestOptions;
+
+/**
+ * @group jsonapi
+ */
+class UserTest extends ResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'user';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'user--user';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = [
+    'changed' => NULL,
+  ];
+
+  /**
+   * @var \Drupal\taxonomy\TermInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $labelFieldName = 'name';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $firstCreatedEntityId = 4;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $secondCreatedEntityId = 5;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    // @todo Remove this in
+    $this->grantPermissionsToTestedRole(['access content']);
+
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['access user profiles']);
+        break;
+      case 'POST':
+      case 'PATCH':
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['administer users']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    // Create a "Llama" user.
+    $user = User::create(['created' => 123456789]);
+    $user->setUsername('Llama')
+      ->setChangedTime(123456789)
+      ->activate()
+      ->save();
+
+    return $user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/user/user/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'user--user',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'created' => 123456789,
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'created' => $this->formatExpectedTimestampItemValues(123456789),
+          'changed' => $this->entity->getChangedTime(),
+          // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932
+//          'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+          'default_langcode' => TRUE,
+          'langcode' => 'en',
+          'name' => 'Llama',
+          'uid' => 3,
+          'uuid' => $this->entity->uuid(),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'data' => [
+        'type' => 'user--user',
+        'attributes' => [
+          'name' => 'Dramallama',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    switch ($method) {
+      case 'GET':
+        return "The 'access user profiles' permission is required and the user must be active.";
+      case 'PATCH':
+      case 'DELETE':
+        return '';
+      default:
+        return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields of the logged in account.
+   */
+  public function testPatchDxForSecuritySensitiveBaseFields() {
+    $original_normalization = $this->entityToJsonApi->normalize($this->account);
+    // @todo Remove the array_diff_key() call in https://www.drupal.org/node/2821077.
+    $original_normalization['data']['attributes'] = array_diff_key($original_normalization['data']['attributes'], ['created' => TRUE, 'changed' => TRUE, 'name' => TRUE]);
+
+    // Since this test must be performed by the user that is being modified,
+    // we must use $this->account, not $this->entity.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['user' => $this->account->uuid()]);
+    //$url = $this->account->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+
+    // Test case 1: changing email.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing email without providing the password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'mail: Your current password is missing or incorrect; it\'s required to change the Email.',
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/mail',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, Json::encode($expected), $response);
+
+    $normalization['data']['attributes']['pass']['existing'] = 'wrong';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing email while providing a wrong password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(422, Json::encode($expected), $response);
+
+    $normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'user:2'], $this->getExpectedCacheContexts());
+
+    // Test case 2: changing password.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $new_password = $this->randomString();
+    $normalization['data']['attributes']['pass']['value'] = $new_password;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 422 when changing password without providing the current password.
+    $response = $this->request('PATCH', $url, $request_options);
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Unprocessable Entity',
+          'status' => 422,
+          'detail' => 'pass: Your current password is missing or incorrect; it\'s required to change the Password.',
+          'code' => 0,
+          'source' => [
+            'pointer' => '/data/attributes/pass',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(422, Json::encode($expected), $response);
+
+    $normalization['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'user:2'], $this->getExpectedCacheContexts());
+
+    // Verify that we can log in with the new password.
+    $this->assertRpcLogin($this->account->getAccountName(), $new_password);
+
+    // Update password in $this->account, prepare for future requests.
+    $this->account->passRaw = $new_password;
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+
+    // Test case 3: changing name.
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $normalization['data']['attributes']['pass']['existing'] = $new_password;
+    $normalization['data']['attributes']['name'] = 'Cooler Llama';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // DX: 403 when modifying username without required permission.
+    $response = $this->request('PATCH', $url, $request_options);
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to PATCH the selected field (name).',
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/user--user/' . $this->account->uuid(),
+          'source' => [
+            'pointer' => '/data/attributes/name',
+          ],
+        ],
+      ],
+    ];
+    $this->assertResourceResponse(403, Json::encode($expected), $response);
+
+    $this->grantPermissionsToTestedRole(['change own username']);
+
+    // 200 for well-formed request.
+    $response = $this->request('PATCH', $url, $request_options);
+    $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'user:2'], $this->getExpectedCacheContexts());
+
+    // Verify that we can log in with the new username.
+    $this->assertRpcLogin('Cooler Llama', $new_password);
+  }
+
+  /**
+   * Verifies that logging in with the given username and password works.
+   *
+   * @param string $username
+   *   The username to log in with.
+   * @param string $password
+   *   The password to log in with.
+   */
+  protected function assertRpcLogin($username, $password) {
+    $request_body = [
+      'name' => $username,
+      'pass' => $password,
+    ];
+    $request_options = [
+      RequestOptions::HEADERS => [],
+      RequestOptions::BODY => Json::encode($request_body),
+    ];
+    $response = $this->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json'), $request_options);
+    $this->assertSame(200, $response->getStatusCode());
+  }
+
+  /**
+   * Tests PATCHing security-sensitive base fields to change other users.
+   */
+  public function testPatchSecurityOtherUser() {
+    $original_normalization = $this->entityToJsonApi->normalize($this->account);
+
+    // Since this test must be performed by the user that is being modified,
+    // we must use $this->account, not $this->entity.
+    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
+    $url = Url::fromRoute(sprintf('jsonapi.user--user.individual'), ['user' => $this->account->uuid()]);
+    //$url = $this->account->toUrl('jsonapi');
+    $request_options = $this->getAuthenticationRequestOptions('PATCH');
+
+    $normalization = $original_normalization;
+    $normalization['data']['attributes']['mail'] = 'new-email@example.com';
+    $request_options[RequestOptions::BODY] = Json::encode($normalization);
+
+    // Try changing user 1's email.
+    $user1 = $original_normalization;
+    $user1['data']['attributes']['mail'] = 'another_email_address@example.com';
+    $user1['data']['attributes']['uid'] = 1;
+    $user1['data']['attributes']['name'] = 'another_user_name';
+    $user1['data']['attributes']['pass']['existing'] = $this->account->passRaw;
+    $request_options[RequestOptions::BODY] = Json::encode($user1);
+    $response = $this->request('PATCH', $url, $request_options);
+    // Ensure the email address has not changed.
+    $this->assertEquals('admin@example.com', $this->entityStorage->loadUnchanged(1)->getEmail());
+    $expected = [
+      'errors' => [
+        [
+          'title' => 'Forbidden',
+          'status' => 403,
+          'detail' => 'The current user is not allowed to PATCH the selected field (uid). The entity ID cannot be changed',
+          'links' => [
+            'info' => HttpExceptionNormalizer::getInfoUrl(403),
+          ],
+          'code' => 0,
+          'id' => '/user--user/' . $this->account->uuid(),
+          'source' => [
+            'pointer' => '/data/attributes/uid',
+          ],
+        ],
+      ],
+    ];
+    // @todo Uncomment this assertion in https://www.drupal.org/project/jsonapi/issues/2939810.
+    // $this->assertResourceResponse(403, Json::encode($expected), $response);
+  }
+
+}
diff --git a/tests/src/Functional/VocabularyTest.php b/tests/src/Functional/VocabularyTest.php
new file mode 100644
index 0000000..6c0ba73
--- /dev/null
+++ b/tests/src/Functional/VocabularyTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Functional;
+
+use Drupal\Core\Url;
+use Drupal\taxonomy\Entity\Vocabulary;
+
+/**
+ * @group jsonapi
+ */
+class VocabularyTest extends ResourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['taxonomy'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'taxonomy_vocabulary';
+
+  /**
+   * @var string
+   */
+  protected static $resourceTypeName = 'taxonomy_vocabulary--taxonomy_vocabulary';
+
+  /**
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    $this->grantPermissionsToTestedRole(['administer taxonomy']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $vocabulary = Vocabulary::create([
+      'name' => 'Llama',
+      'vid' => 'llama',
+    ]);
+    $vocabulary->save();
+
+    return $vocabulary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $self_url = Url::fromUri('base:/jsonapi/taxonomy_vocabulary/taxonomy_vocabulary/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl();
+    return [
+      'jsonapi' => [
+        'meta' => [
+          'links' => [
+            'self' => 'http://jsonapi.org/format/1.0/',
+          ],
+        ],
+        'version' => '1.0',
+      ],
+      'links' => [
+        'self' => $self_url,
+      ],
+      'data' => [
+        'id' => $this->entity->uuid(),
+        'type' => 'taxonomy_vocabulary--taxonomy_vocabulary',
+        'links' => [
+          'self' => $self_url,
+        ],
+        'attributes' => [
+          'uuid' => $this->entity->uuid(),
+          'vid' => 'llama',
+          'langcode' => 'en',
+          'status' => TRUE,
+          'dependencies' => [],
+          'name' => 'Llama',
+          'description' => NULL,
+          'hierarchy' => 0,
+          'weight' => 0,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    // @todo Update in https://www.drupal.org/node/2300677.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($method === 'GET') {
+      return "The following permissions are required: 'access taxonomy overview' OR 'administer taxonomy'.";
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+}
