.../EntityResource/Block/BlockHalJsonAnonTest.php | 35 +
.../Block/BlockHalJsonBasicAuthTest.php | 46 +
.../Block/BlockHalJsonCookieTest.php | 40 +
.../Comment/CommentHalJsonAnonTest.php | 35 +
.../Comment/CommentHalJsonBasicAuthTest.php | 30 +
.../Comment/CommentHalJsonCookieTest.php | 19 +
.../Comment/CommentHalJsonTestBase.php | 144 +++
.../ConfigTest/ConfigTestHalJsonAnonTest.php | 35 +
.../ConfigTest/ConfigTestHalJsonBasicAuthTest.php | 46 +
.../ConfigTest/ConfigTestHalJsonCookieTest.php | 40 +
.../EntityTest/EntityTestHalJsonAnonTest.php | 105 ++
.../EntityTest/EntityTestHalJsonBasicAuthTest.php | 30 +
.../EntityTest/EntityTestHalJsonCookieTest.php | 19 +
.../EntityResource/HalEntityNormalizationTrait.php | 128 +++
.../EntityResource/Node/NodeHalJsonAnonTest.php | 137 +++
.../Node/NodeHalJsonBasicAuthTest.php | 30 +
.../EntityResource/Node/NodeHalJsonCookieTest.php | 19 +
.../EntityResource/Role/RoleHalJsonAnonTest.php | 35 +
.../Role/RoleHalJsonBasicAuthTest.php | 46 +
.../EntityResource/Role/RoleHalJsonCookieTest.php | 40 +
.../EntityResource/Term/TermHalJsonAnonTest.php | 79 ++
.../Term/TermHalJsonBasicAuthTest.php | 30 +
.../EntityResource/Term/TermHalJsonCookieTest.php | 18 +
.../EntityResource/User/UserHalJsonAnonTest.php | 79 ++
.../User/UserHalJsonBasicAuthTest.php | 30 +
.../EntityResource/User/UserHalJsonCookieTest.php | 19 +
.../Vocabulary/VocabularyHalJsonAnonTest.php | 42 +
.../Vocabulary/VocabularyHalJsonBasicAuthTest.php | 46 +
.../Vocabulary/VocabularyHalJsonCookieTest.php | 40 +
.../HalJsonBasicAuthWorkaroundFor2805281Trait.php | 25 +
.../config_test_rest/config_test_rest.info.yml | 7 +
.../config_test_rest/config_test_rest.module | 26 +
.../config_test_rest.permissions.yml | 2 +
.../rest/tests/modules/rest_test/rest_test.module | 26 +
.../tests/src/Functional/AnonResourceTestTrait.php | 36 +
.../src/Functional/BasicAuthResourceTestTrait.php | 43 +
.../src/Functional/CookieResourceTestTrait.php | 129 +++
.../EntityResource/Block/BlockJsonAnonTest.php | 29 +
.../Block/BlockJsonBasicAuthTest.php | 45 +
.../EntityResource/Block/BlockJsonCookieTest.php | 34 +
.../EntityResource/Block/BlockResourceTestBase.php | 130 +++
.../EntityResource/Comment/CommentJsonAnonTest.php | 50 +
.../Comment/CommentJsonBasicAuthTest.php | 46 +
.../Comment/CommentJsonCookieTest.php | 34 +
.../Comment/CommentResourceTestBase.php | 309 ++++++
.../ConfigTest/ConfigTestJsonAnonTest.php | 29 +
.../ConfigTest/ConfigTestJsonBasicAuthTest.php | 45 +
.../ConfigTest/ConfigTestJsonCookieTest.php | 34 +
.../ConfigTest/ConfigTestResourceTestBase.php | 73 ++
.../EntityResource/EntityResourceTestBase.php | 1088 ++++++++++++++++++++
.../EntityTest/EntityTestJsonAnonTest.php | 29 +
.../EntityTest/EntityTestJsonBasicAuthTest.php | 45 +
.../EntityTest/EntityTestJsonCookieTest.php | 34 +
.../EntityTest/EntityTestResourceTestBase.php | 127 +++
.../EntityResource/Node/NodeJsonAnonTest.php | 29 +
.../EntityResource/Node/NodeJsonBasicAuthTest.php | 46 +
.../EntityResource/Node/NodeJsonCookieTest.php | 34 +
.../EntityResource/Node/NodeResourceTestBase.php | 196 ++++
.../EntityResource/Role/RoleJsonAnonTest.php | 29 +
.../EntityResource/Role/RoleJsonBasicAuthTest.php | 48 +
.../EntityResource/Role/RoleJsonCookieTest.php | 36 +
.../EntityResource/Role/RoleResourceTestBase.php | 70 ++
.../EntityResource/Term/TermJsonAnonTest.php | 29 +
.../EntityResource/Term/TermJsonBasicAuthTest.php | 45 +
.../EntityResource/Term/TermJsonCookieTest.php | 34 +
.../EntityResource/Term/TermResourceTestBase.php | 140 +++
.../EntityResource/User/UserJsonAnonTest.php | 29 +
.../EntityResource/User/UserJsonBasicAuthTest.php | 45 +
.../EntityResource/User/UserJsonCookieTest.php | 34 +
.../EntityResource/User/UserResourceTestBase.php | 232 +++++
.../Vocabulary/VocabularyJsonAnonTest.php | 37 +
.../Vocabulary/VocabularyJsonBasicAuthTest.php | 45 +
.../Vocabulary/VocabularyJsonCookieTest.php | 34 +
.../Vocabulary/VocabularyResourceTestBase.php | 72 ++
.../JsonBasicAuthWorkaroundFor2805281Trait.php | 25 +
.../rest/tests/src/Functional/ResourceTestBase.php | 353 +++++++
76 files changed, 5559 insertions(+)
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
new file mode 100644
index 0000000..d0758f3
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+applyHalFieldNormalization($default_normalization);
+
+ // Because \Drupal\comment\Entity\Comment::getOwner() generates an in-memory
+ // User entity without a UUID, we cannot use it.
+ $author = User::load($this->entity->getOwnerId());
+ $commented_entity = EntityTest::load(1);
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/comment/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/comment/comment',
+ ],
+ $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [
+ [
+ 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/comment/comment/uid' => [
+ [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ 'lang' => 'en',
+ ],
+ ],
+ ],
+ '_embedded' => [
+ $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/entity_test/bar',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $commented_entity->uuid()]
+ ],
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/comment/comment/uid' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ 'lang' => 'en',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/comment/comment',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // The 'url.site' cache context is added for '_links' in the response.
+ return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']);
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php
new file mode 100644
index 0000000..45cedb2
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php
@@ -0,0 +1,35 @@
+applyHalFieldNormalization($default_normalization);
+
+ $author = User::load(0);
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test',
+ ],
+ $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [
+ [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ 'lang' => 'en',
+ ],
+ ],
+ ],
+ '_embedded' => [
+ $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ 'lang' => 'en',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // The 'url.site' cache context is added for '_links' in the response.
+ return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']);
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..5604e3b
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+entity instanceof FieldableEntityInterface) {
+ throw new \LogicException('This trait should only be used for fieldable entity types.');
+ }
+
+ // In the HAL normalization, all translatable fields get a 'lang' attribute.
+ $translatable_non_reference_fields = array_keys(array_filter($this->entity->getTranslatableFields(), function (FieldItemListInterface $field) {
+ return !$field instanceof EntityReferenceFieldItemListInterface;
+ }));
+ foreach ($translatable_non_reference_fields as $field_name) {
+ if (isset($normalization[$field_name])) {
+ $normalization[$field_name][0]['lang'] = 'en';
+ }
+ }
+
+ // In the HAL normalization, reference fields are omitted, except for the
+ // bundle field.
+ $bundle_key = $this->entity->getEntityType()->getKey('bundle');
+ $reference_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) use ($bundle_key) {
+ return $field instanceof EntityReferenceFieldItemListInterface && $field->getName() !== $bundle_key;
+ }));
+ foreach ($reference_fields as $field_name) {
+ unset($normalization[$field_name]);
+ }
+
+ // In the HAL normalization, the bundle field omits the 'target_type' and
+ // 'target_uuid' properties, because it's encoded in the '_links' section.
+ if ($bundle_key) {
+ unset($normalization[$bundle_key][0]['target_type']);
+ unset($normalization[$bundle_key][0]['target_uuid']);
+ }
+
+ // In the HAL normalization, empty fields are omitted.
+ $empty_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) {
+ return $field->isEmpty();
+ }));
+ foreach ($empty_fields as $field_name) {
+ unset($normalization[$field_name]);
+ }
+
+ return $normalization;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function removeFieldsFromNormalization(array $normalization, $field_names) {
+ $normalization = parent::removeFieldsFromNormalization($normalization, $field_names);
+ foreach ($field_names as $field_name) {
+ $relation_url = Url::fromUri('base:rest/relation/' . static::$entityTypeId . '/' . $this->entity->bundle() . '/' . $field_name)
+ ->setAbsolute(TRUE)
+ ->toString();
+ $normalization['_links'] = array_diff_key($normalization['_links'], [$relation_url => TRUE]);
+ if (isset($normalization['_embedded'])) {
+ $normalization['_embedded'] = array_diff_key($normalization['_embedded'], [$relation_url => TRUE]);
+ }
+ }
+
+ return array_diff_key($normalization, array_flip($field_names));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
+ // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
+ // types with bundles MUST send their bundle field to be denormalizable.
+ if ($this->entity->getEntityType()->hasKey('bundle')) {
+ $normalization = $this->getNormalizedPostEntity();
+
+ // @todo Uncomment this in https://www.drupal.org/node/2824827.
+/*
+ $normalization['_links']['type'] = Url::fromUri('base:rest/type/' . static::$entityTypeId . '/bad_bundle_name');
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+ // DX: 400 when incorrect entity type bundle is specified.
+ $response = $this->request($method, $url, $request_options);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+// $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody());
+*/
+
+ unset($normalization['_links']['type']);
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // DX: 400 when no entity type bundle is specified.
+ $response = $this->request($method, $url, $request_options);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+// $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody());
+ }
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
new file mode 100644
index 0000000..c7f2428
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -0,0 +1,137 @@
+applyHalFieldNormalization($default_normalization);
+
+ $author = User::load($this->entity->getOwnerId());
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/node/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/node/camelids',
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+ [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ 'lang' => 'en',
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+ [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ ],
+ ],
+ ],
+ '_embedded' => [
+ $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ 'lang' => 'en',
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/node/camelids',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // The 'url.site' cache context is added for '_links' in the response.
+ return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..1d7bb62
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+applyHalFieldNormalization($default_normalization);
+
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/taxonomy/term/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // The 'url.site' cache context is added for '_links' in the response.
+ return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..8c7b04b
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+applyHalFieldNormalization($default_normalization);
+
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/3?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // The 'url.site' cache context is added for '_links' in the response.
+ return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']);
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..dbf17cb
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+markTestSkipped();
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..4f7896e
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php
@@ -0,0 +1,46 @@
+assertSame(401, $response->getStatusCode());
+ // @todo this works fine locally, but on testbot it comes back with
+ // 'text/plain; charset=UTF-8'. WTF.
+// $this->assertSame(['application/hal+json'], $response->getHeader('Content-Type'));
+ $this->assertSame('No authentication credentials provided.', (string) $response->getBody());
+ }
+}
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml
new file mode 100644
index 0000000..cf9efee
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml
@@ -0,0 +1,7 @@
+name: 'Configuration test REST'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+ - config_test
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module
new file mode 100644
index 0000000..f7e24a2
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module
@@ -0,0 +1,26 @@
+hasPermission('view config_test'))->cachePerPermissions();
+}
diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml
new file mode 100644
index 0000000..b8fd229
--- /dev/null
+++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml
@@ -0,0 +1,2 @@
+view config_test:
+ title: 'View ConfigTest entities'
diff --git a/core/modules/rest/tests/modules/rest_test/rest_test.module b/core/modules/rest/tests/modules/rest_test/rest_test.module
index 272603d..01511ea 100644
--- a/core/modules/rest/tests/modules/rest_test/rest_test.module
+++ b/core/modules/rest/tests/modules/rest_test/rest_test.module
@@ -5,6 +5,11 @@
* Contains hook implementations for testing REST module.
*/
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Access\AccessResult;
+
/**
* Implements hook_rest_type_uri_alter().
*/
@@ -22,3 +27,24 @@ function rest_test_rest_relation_uri_alter(&$uri, $context = array()) {
$uri = 'rest_test_relation';
}
}
+
+/**
+ * Implements hook_entity_field_access().
+ *
+ * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::setUp()
+ * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost()
+ */
+function rest_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
+ if ($field_definition->getName() === 'field_rest_test') {
+ switch ($operation) {
+ case 'view':
+ // Never ever allow this field to be viewed: this lets EntityResourceTestBase::testGet() test in a "vanilla" way.
+ return AccessResult::forbidden();
+ case 'edit':
+ return AccessResult::forbidden();
+ }
+ }
+
+ // No opinion.
+ return AccessResult::neutral();
+}
diff --git a/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
new file mode 100644
index 0000000..b05ddf2
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
@@ -0,0 +1,36 @@
+ [
+ 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+ $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {}
+
+}
diff --git a/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php
new file mode 100644
index 0000000..c231ffb
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php
@@ -0,0 +1,129 @@
+setRouteParameter('_format', 'json');
+
+ $request_body = [
+ 'name' => $this->account->name->value,
+ 'pass' => $this->account->passRaw,
+ ];
+
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, 'json');
+ $request_options[RequestOptions::HEADERS]['Accept'] = 'application/json';
+ $response = $this->request('POST', $user_login_url, $request_options);
+
+ // Parse and store the session cookie.
+ $this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0];
+
+ // Parse and store the CSRF token and logout token.
+ $data = $this->serializer->decode($response->getBody()->getContents(), static::$format);
+ $this->csrfToken = $data['csrf_token'];
+ $this->logoutToken = $data['logout_token'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getAuthenticationRequestOptions($method) {
+ $request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie;
+ // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+ if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
+ $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
+ }
+ return $request_options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) {
+ $this->assertResourceErrorResponse(403, '', $response);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
+ // X-CSRF-Token request header is unnecessary for safe and side effect-free
+ // HTTP methods. No need for additional assertions.
+ // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
+ if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
+ return;
+ }
+
+
+ unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']);
+
+
+ // DX: 403 when missing X-CSRF-Token request header.
+ $response = $this->request($method, $url, $request_options);
+ $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response);
+
+
+ $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for';
+
+
+ // DX: 403 when invalid X-CSRF-Token request header.
+ $response = $this->request($method, $url, $request_options);
+ $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response);
+
+
+ $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php
new file mode 100644
index 0000000..9c764bd
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php
@@ -0,0 +1,29 @@
+entity->setVisibilityConfig('user_role', [])->save();
+ break;
+ case 'POST':
+ $this->grantPermissionsToTestedRole(['administer blocks']);
+ break;
+ case 'PATCH':
+ $this->grantPermissionsToTestedRole(['administer blocks']);
+ break;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function createEntity() {
+ $block = Block::create([
+ 'plugin' => 'llama_block',
+ 'region' => 'header',
+ 'id' => 'llama',
+ 'theme' => 'classy',
+ ]);
+ // All blocks can be viewed by the anonymous user by default. An interesting
+ // side effect of this is that any anonymous user is also able to read the
+ // corresponding block config entity via REST, even if an authentication
+ // provider is configured for the block config entity REST resource! In
+ // other words: Block entities do not distinguish between 'view' as in
+ // "render on a page" and 'view' as in "read the configuration".
+ // This prevents that.
+ // @todo 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() {
+ $normalization = [
+ 'uuid' => $this->entity->uuid(),
+ 'id' => 'llama',
+ 'weight' => NULL,
+ 'langcode' => 'en',
+ 'status' => TRUE,
+ 'dependencies' => [
+ 'theme' => [
+ 'classy',
+ ],
+ ],
+ 'theme' => 'classy',
+ 'region' => 'header',
+ 'provider' => NULL,
+ 'plugin' => 'llama_block',
+ 'settings' => [
+ 'id' => 'broken',
+ 'label' => '',
+ 'provider' => 'core',
+ 'label_display' => 'visible',
+ ],
+ 'visibility' => [],
+ ];
+
+ return $normalization;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ // @todo Update in https://www.drupal.org/node/2300677.
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedCacheContexts() {
+ // @see ::createEntity()
+ return [];
+ }
+
+ /**
+ * {@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_filter(parent::getExpectedCacheTags(), function ($tag) {
+ return $tag !== 'config:user.role.anonymous';
+ });
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php
new file mode 100644
index 0000000..6ce580d
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php
@@ -0,0 +1,50 @@
+grantPermissionsToTestedRole(['access comments', 'view test entity']);
+ break;
+ case 'POST':
+ $this->grantPermissionsToTestedRole(['post comments']);
+ break;
+ case 'PATCH':
+ // Anononymous users are not ever allowed to edit their own comments. To
+ // be able to test PATCHing comments as the anonymous user, the more
+ // permissive 'administer comments' permission must be granted.
+ // @see \Drupal\comment\CommentAccessControlHandler::checkAccess
+ if (static::$auth) {
+ $this->grantPermissionsToTestedRole(['edit own comments']);
+ }
+ else {
+ $this->grantPermissionsToTestedRole(['administer 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(array(
+ '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(static::$auth ? $this->account->id() : 0)
+ ->setPublished(TRUE)
+ ->setCreatedTime(123456789)
+ ->setChangedTime(123456789);
+ $comment->save();
+
+ return $comment;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load($this->entity->getOwnerId());
+ return [
+ 'cid' => [
+ ['value' => 1],
+ ],
+ 'uuid' => [
+ ['value' => $this->entity->uuid()],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'comment_type' => [
+ [
+ 'target_id' => 'comment',
+ 'target_type' => 'comment_type',
+ 'target_uuid' => CommentType::load('comment')->uuid(),
+ ],
+ ],
+ 'subject' => [
+ [
+ 'value' => 'Llama',
+ ],
+ ],
+ 'status' => [
+ [
+ 'value' => 1,
+ ],
+ ],
+ 'created' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'changed' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'default_langcode' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ 'uid' => [
+ [
+ 'target_id' => $author->id(),
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/' . $author->id(),
+ ],
+ ],
+ 'pid' => [],
+ 'entity_type' => [
+ [
+ 'value' => 'entity_test',
+ ],
+ ],
+ 'entity_id' => [
+ [
+ 'target_id' => '1',
+ 'target_type' => 'entity_test',
+ 'target_uuid' => EntityTest::load(1)->uuid(),
+ 'url' => base_path() . 'entity_test/1',
+ ],
+ ],
+ 'field_name' => [
+ [
+ 'value' => 'comment',
+ ],
+ ],
+ 'name' => [],
+ 'homepage' => [],
+ 'thread' => [
+ [
+ 'value' => '01/',
+ ],
+ ],
+ 'comment_body' => [
+ [
+ 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
+ 'format' => 'plain_text',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'comment_type' => [
+ [
+ 'target_id' => 'comment',
+ ],
+ ],
+ 'entity_type' => [
+ [
+ 'value' => 'entity_test',
+ ],
+ ],
+ 'entity_id' => [
+ [
+ 'target_id' => EntityTest::load(1)->id(),
+ ],
+ ],
+ 'field_name' => [
+ [
+ 'value' => 'comment',
+ ],
+ ],
+ 'subject' => [
+ [
+ 'value' => 'Dramallama',
+ ],
+ ],
+ 'comment_body' => [
+ [
+ 'value' => 'Llamas are awesome.',
+ 'format' => 'plain_text',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPatchEntity() {
+ return array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE, 'entity_id' => TRUE, 'field_name' => TRUE]);
+ }
+
+ /**
+ * Tests POSTing a comment without critical base fields.
+ *
+ * testPost() 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 testPostDxWithoutCriticalBaseFields() {
+ $this->initAuthentication();
+ $this->provisionEntityResource();
+ $this->setUpAuthorization('POST');
+
+ $url = $this->getPostUrl()->setOption('query', ['_format' => static::$format]);
+ $request_options = [];
+ $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+ $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+ $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+ // DX: 422 when missing 'entity_type' field.
+ $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE]), static::$format);
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+ $this->assertResourceErrorResponse(500, 'A fatal error occurred: Internal Server Error', $response);
+ //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response);
+
+ // DX: 422 when missing 'entity_id' field.
+ $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_id' => TRUE]), static::$format);
+ // @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 Wim's local machine.
+ $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: validation failed.\nentity_type: This value should not be null.\n", $response);
+
+ // DX: 422 when missing 'entity_type' field.
+ $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['field_name' => TRUE]), static::$format);
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364.
+ $this->assertResourceErrorResponse(500, 'A fatal error occurred: Field is unknown.', $response);
+ //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nfield_name: This value should not be null.\n", $response);
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php
new file mode 100644
index 0000000..db79e6c
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php
@@ -0,0 +1,29 @@
+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() {
+ $normalization = [
+ 'uuid' => $this->entity->uuid(),
+ 'id' => 'llama',
+ 'weight' => 0,
+ 'langcode' => 'en',
+ 'status' => TRUE,
+ 'dependencies' => [],
+ 'label' => 'Llama',
+ 'style' => NULL,
+ 'size' => NULL,
+ 'size_value' => NULL,
+ 'protected_property' => NULL,
+ ];
+
+ return $normalization;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ // @todo Update in https://www.drupal.org/node/2300677.
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
new file mode 100644
index 0000000..41625fe
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,1088 @@
+provisionResource('entity.' . static::$entityTypeId, [static::$format], $auth);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setUp() {
+ parent::setUp();
+
+ $this->serializer = $this->container->get('serializer');
+ $this->entityStorage = $this->container->get('entity_type.manager')
+ ->getStorage(static::$entityTypeId);
+
+ // Set up a HTTP client that accepts relative URLs.
+ $this->httpClient = $this->container->get('http_client_factory')
+ ->fromOptions(['base_uri' => $this->baseUrl]);
+
+ // Create an entity.
+ $this->entity = $this->createEntity();
+
+ 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();
+
+ // Reload entity so that it has the new field.
+ $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
+
+ // Set a default value on the field.
+ $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
+ // @todo Remove in this if-test in https://www.drupal.org/node/2808335.
+ if ($this->entity instanceof EntityChangedInterface) {
+ $changed = $this->entity->getChangedTime();
+ $this->entity->setChangedTime(42);
+ $this->entity->save();
+ $this->entity->setChangedTime($changed);
+ }
+ $this->entity->save();
+ }
+
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+ }
+
+ /**
+ * Creates the entity to be tested.
+ *
+ * @return \Drupal\Core\Entity\EntityInterface
+ * The entity to be tested.
+ */
+ abstract protected function createEntity();
+
+ /**
+ * Returns the expected normalization of the entity.
+ *
+ * @see ::createEntity()
+ *
+ * @return array
+ */
+ abstract protected function getExpectedNormalizedEntity();
+
+ /**
+ * 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 $this->getNormalizedPostEntity();
+ }
+
+ /**
+ * The expected cache tags for the GET/HEAD response of the test entity.
+ *
+ * @see ::testGet
+ *
+ * @return string[]
+ */
+ protected function getExpectedCacheTags() {
+ $expected_cache_tags = [
+ 'config:rest.resource.entity.' . static::$entityTypeId,
+ ];
+ if (!static::$auth) {
+ $expected_cache_tags[] = 'config:user.role.anonymous';
+ }
+ 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 [
+ 'user.permissions',
+ ];
+ }
+
+ /**
+ * Test a GET request for an entity, plus edge cases to ensure good DX.
+ */
+ public function testGet() {
+ $this->initAuthentication();
+ $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+ // The URL and Guzzle request options that will be used in this test. The
+ // request options will be modified/expanded throughout this test:
+ // - to first test all mistakes a developer might make, and assert that the
+ // error responses provide a good DX
+ // - to eventually result in a well-formed request that succeeds.
+ $url = $this->getUrl();
+ $request_options = [];
+
+
+ // DX: 404 when resource not provisioned, 403 if canonical route. HTML
+ // response because missing ?_format query string.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
+ // response because ?_format query string is present.
+ $response = $this->request('GET', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertResourceErrorResponse(403, '', $response);
+ }
+ else {
+ $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+ }
+
+
+ $this->provisionEntityResource();
+ // Simulate the developer again forgetting the ?_format query string.
+ $url->setOption('query', []);
+
+
+
+ // DX: 406 when ?_format is missing, except when requesting a canonical HTML
+ // route.
+ $response = $this->request('GET', $url, $request_options);
+ if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
+ $this->assertSame(403, $response->getStatusCode());
+ }
+ else {
+ $this->assert406Response($response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: forgetting authentication: authentication provider-specific error
+ // response.
+ if (static::$auth) {
+ $response = $this->request('GET', $url, $request_options);
+ $this->assertResponseWhenMissingAuthentication($response);
+ }
+
+ $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('GET', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->setUpAuthorization('GET');
+
+
+ // 200 for well-formed HEAD request.
+ $response = $this->request('HEAD', $url, $request_options);
+ $this->assertResourceResponse(200, '', $response);
+ if (!$this->account) {
+ $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache'));
+ }
+ else {
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ }
+ $head_headers = $response->getHeaders();
+
+ // 200 for well-formed GET request. Page Cache hit because of HEAD request.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+ if (!static::$auth) {
+ $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache'));
+ }
+ else {
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ }
+ $cache_tags_header_value = $response->getHeader('X-Drupal-Cache-Tags')[0];
+ $this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value));
+ $cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0];
+ $this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value));
+ // Comparing the exact serialization is pointless, because the order of
+ // fields does not matter (at least not yet). That's why we only compare the
+ // normalized entity with the decoded response: it's comparing PHP arrays
+ // instead of strings.
+ $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string) $response->getBody(), static::$format));
+ // Not only assert the normalization, also assert deserialization of the
+ // response results in the expected object.
+ $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
+ $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+ $get_headers = $response->getHeaders();
+
+ // Verify that the GET and HEAD responses are the same. The only difference
+ // is that there's no body.
+ $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache'];
+ foreach ($ignored_headers as $ignored_header) {
+ unset($head_headers[$ignored_header]);
+ unset($get_headers[$ignored_header]);
+ }
+ $this->assertSame($get_headers, $head_headers);
+
+
+ $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('GET', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
+
+
+ // 200 for well-formed request.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+
+
+ $url->setOption('query', ['_format' => 'non_existing_format']);
+
+
+ // DX: 406 when requesting unsupported format.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assert406Response($response);
+ $this->assertNotSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+ $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+
+
+ // DX: 406 when requesting unsupported format but specifying Accept header.
+ // @todo Update in https://www.drupal.org/node/2825347.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assert406Response($response);
+ $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+ $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format);
+ $url->setRouteParameter(static::$entityTypeId, 987654321);
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 404 when GETting non-existing entity.
+ $response = $this->request('GET', $url, $request_options);
+ $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+ $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET.' . static::$format . '")';
+ $this->assertResourceErrorResponse(404, $message, $response);
+ }
+
+ /**
+ * Tests a POST request for an entity, plus edge cases to ensure good DX.
+ */
+ public function testPost() {
+ // @todo Remove this in https://www.drupal.org/node/2300677.
+ if ($this->entity instanceof ConfigEntityInterface) {
+ $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
+ return;
+ }
+
+ $this->initAuthentication();
+ $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+ // Try with all of the following request bodies.
+ $unparseable_request_body = '!{>}<';
+ $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
+ $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
+ $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format);
+ // @todo Change to ['uuid' => UUID] in https://www.drupal.org/node/2820743.
+ $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [['value' => $this->randomMachineName(129)]]], static::$format);
+ $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
+
+ // The URL and Guzzle request options that will be used in this test. The
+ // request options will be modified/expanded throughout this test:
+ // - to first test all mistakes a developer might make, and assert that the
+ // error responses provide a good DX
+ // - to eventually result in a well-formed request that succeeds.
+ $url = $this->getPostUrl();
+ $request_options = [];
+
+
+ // DX: 404 when resource not provisioned, but HTML if canonical route.
+ $response = $this->request('POST', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertSame(404, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+ }
+ else {
+ $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 404 when resource not provisioned.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getPostUrl()->setAbsolute()->toString()) . '"', $response);
+
+
+ $this->provisionEntityResource();
+ // Simulate the developer again forgetting the ?_format query string.
+ $url->setOption('query', []);
+
+
+ // DX: 415 when no Content-Type request header, but HTML if canonical route.
+ $response = $this->request('POST', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertSame(415, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+ $this->assertContains(htmlspecialchars('No "Content-Type" request header specified'), $response->getBody()->getContents());
+ }
+ else {
+ $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 415 when no Content-Type request header.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+
+
+ $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+ // DX: 400 when no request body.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
+
+
+ $request_options[RequestOptions::BODY] = $unparseable_request_body;
+
+
+ // DX: 400 when unparseable request body.
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853.
+// $this->assertResourceErrorResponse(400, 'Syntax error', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody());
+
+
+
+ $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+
+ if (static::$auth) {
+ // DX: forgetting authentication: authentication provider-specific error
+ // response.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResponseWhenMissingAuthentication($response);
+ }
+
+
+ $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->setUpAuthorization('POST');
+
+
+ // 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 = ucfirst($label_field);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: Title: this field cannot hold more than 1 values.\n", $response);
+ $this->assertSame(422, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody());
+
+
+ $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
+
+
+ // DX: 422 when invalid entity: UUID field too long.
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
+ $this->assertSame(422, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n"], static::$format), (string) $response->getBody());
+
+
+ $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);
+ // @todo Add trailing period in https://www.drupal.org/node/2821013.
+ $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'", $response);
+
+
+ $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+
+ // Before sending a well-formed request, allow the normalization and
+ // authentication provider edge cases to also be tested.
+ $this->assertNormalizationEdgeCases('POST', $url, $request_options);
+ $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
+
+
+ $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);
+ // @todo Update this in https://www.drupal.org/node/2826407. Also move it
+ // higher, before the "no request body" test. That's impossible right now,
+ // because the format validation happens too late.
+ $this->assertResourceErrorResponse(415, '', $response);
+
+
+ $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+ // 201 for well-formed request.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResourceResponse(201, FALSE, $response);
+ $this->assertSame([str_replace($this->entity->id(), static::$firstCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+
+ $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+ $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
+
+
+ // 201 for well-formed request.
+ $response = $this->request('POST', $url, $request_options);
+ $this->assertResourceResponse(201, FALSE, $response);
+ $this->assertSame([str_replace($this->entity->id(), static::$secondCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location'));
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ }
+
+ /**
+ * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
+ */
+ public function testPatch() {
+ // @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;
+ }
+
+ $this->initAuthentication();
+ $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+ // Try with all of the following request bodies.
+ $unparseable_request_body = '!{>}<';
+ $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
+ $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
+ $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
+ $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
+
+ // The URL and Guzzle request options that will be used in this test. The
+ // request options will be modified/expanded throughout this test:
+ // - to first test all mistakes a developer might make, and assert that the
+ // error responses provide a good DX
+ // - to eventually result in a well-formed request that succeeds.
+ $url = $this->getUrl();
+ $request_options = [];
+
+
+ // DX: 405 when resource not provisioned, but HTML if canonical route.
+ $response = $this->request('PATCH', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertSame(405, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+ }
+ else {
+ $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 405 when resource not provisioned.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
+
+
+ $this->provisionEntityResource();
+ // Simulate the developer again forgetting the ?_format query string.
+ $url->setOption('query', []);
+
+
+ // DX: 415 when no Content-Type request header, but HTML if canonical route.
+ $response = $this->request('PATCH', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertSame(415, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+ $this->assertTrue(FALSE !== strpos($response->getBody()->getContents(), htmlspecialchars('No "Content-Type" request header specified')));
+ }
+ else {
+ $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 415 when no Content-Type request header.
+ $response = $this->request('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, remove next 3 in https://www.drupal.org/node/2813853.
+// $this->assertResourceErrorResponse(400, 'Syntax error', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody());
+
+
+
+ $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
+
+
+ if (static::$auth) {
+ // DX: forgetting authentication: authentication provider-specific error
+ // response.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResponseWhenMissingAuthentication($response);
+ }
+
+
+ $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $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 = ucfirst($label_field);
+ // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755.
+// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: Title: this field cannot hold more than 1 values.\n", $response);
+// $this->assertSame(422, $response->getStatusCode());
+// $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody());
+
+
+ $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);
+
+
+ // DX: 403 when sending PATCH request with read-only fields.
+ // First send all fields (the "maximum normalization"). Assert the expected
+ // error message for the first PATCH-protected field. Remove that field from
+ // the normalization, send another request, assert the next PATCH-protected
+ // field error message. And so on.
+ $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
+ for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
+ $max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i));
+ $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response);
+ }
+
+ // 200 for well-formed request that sends the maximum number of fields.
+ $max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames);
+ $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+
+
+ $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+
+ // Before sending a well-formed request, allow the normalization and
+ // authentication provider edge cases to also be tested.
+ $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
+ $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
+
+
+ $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);
+ // @todo Update this in https://www.drupal.org/node/2826407. Also move it
+ // higher, before the "no request body" test. That's impossible right now,
+ // because the format validation happens too late.
+ $this->assertResourceErrorResponse(415, '', $response);
+
+
+ $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ // 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.', $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test')->value);
+
+
+ $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+ $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
+
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ }
+
+ /**
+ * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
+ */
+ public function testDelete() {
+ // @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;
+ }
+
+ $this->initAuthentication();
+ $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+
+ // The URL and Guzzle request options that will be used in this test. The
+ // request options will be modified/expanded throughout this test:
+ // - to first test all mistakes a developer might make, and assert that the
+ // error responses provide a good DX
+ // - to eventually result in a well-formed request that succeeds.
+ $url = $this->getUrl();
+ $request_options = [];
+
+
+ // DX: 405 when resource not provisioned, but HTML if canonical route.
+ $response = $this->request('DELETE', $url, $request_options);
+ if ($has_canonical_url) {
+ $this->assertSame(405, $response->getStatusCode());
+ $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+ }
+ else {
+ $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
+ }
+
+
+ $url->setOption('query', ['_format' => static::$format]);
+
+
+ // DX: 405 when resource not provisioned.
+ $response = $this->request('DELETE', $url, $request_options);
+ $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
+
+
+ $this->provisionEntityResource();
+
+
+ if (static::$auth) {
+ // DX: forgetting authentication: authentication provider-specific error
+ // response.
+ $response = $this->request('DELETE', $url, $request_options);
+ $this->assertResponseWhenMissingAuthentication($response);
+ }
+
+
+ $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('DELETE', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->setUpAuthorization('DELETE');
+
+
+ // Before sending a well-formed request, allow the authentication provider's
+ // edge cases to also be tested.
+ $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
+
+
+ // 204 for well-formed request.
+ $response = $this->request('DELETE', $url, $request_options);
+ $this->assertSame(204, $response->getStatusCode());
+ // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed.
+// $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+ $this->assertSame('', $response->getBody()->getContents());
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+
+
+ $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+ $this->entity = $this->createEntity();
+ $url = $this->getUrl()->setOption('query', $url->getOption('query'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('DELETE', $url, $request_options);
+ // @todo Update the message in https://www.drupal.org/node/2808233.
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
+
+
+ // 204 for well-formed request.
+ $response = $this->request('DELETE', $url, $request_options);
+ $this->assertSame(204, $response->getStatusCode());
+ // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed.
+// $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
+ $this->assertSame('', $response->getBody()->getContents());
+ $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
+ // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
+ // types with bundles MUST send their bundle field to be denormalizable.
+ $entity_type = $this->entity->getEntityType();
+ if ($entity_type->hasKey('bundle')) {
+ $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
+ $normalization = $this->getNormalizedPostEntity();
+
+ // The bundle type itself can be validated only if there's a bundle entity
+ // type.
+ if ($entity_type->getBundleEntityType()) {
+ $normalization[$bundle_field_name] = 'bad_bundle_name';
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // DX: 400 when incorrect entity type bundle is specified.
+ $response = $this->request($method, $url, $request_options);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+ // $this->assertResourceErrorResponse(400, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => '"bad_bundle_name" is not a valid bundle type for denormalization.'], static::$format), (string) $response->getBody());
+ }
+
+
+ unset($normalization[$bundle_field_name]);
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // DX: 400 when no entity type bundle is specified.
+ $response = $this->request($method, $url, $request_options);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+// $this->assertResourceErrorResponse(400, 'A string must be provided as a bundle value.', $response);
+ $this->assertSame(400, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['error' => 'A string must be provided as a bundle value.'], static::$format), (string) $response->getBody());
+ }
+ }
+
+ /**
+ * Gets an entity resource's GET/PATCH/DELETE URL.
+ *
+ * @return \Drupal\Core\Url
+ * The URL to GET/PATCH/DELETE.
+ */
+ protected function getUrl() {
+ $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
+ return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
+ }
+
+ /**
+ * Gets an entity resource's POST URL.
+ *
+ * @return \Drupal\Core\Url
+ * The URL to POST to.
+ */
+ protected function getPostUrl() {
+ $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create');
+ return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId);
+ }
+
+ /**
+ * 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[$label_field][1]['value'] = 'Second Title';
+
+ return $normalization;
+ }
+
+ /**
+ * Removes fields from a normalization.
+ *
+ * @param array $normalization
+ * An entity normalization.
+ * @param string[] $field_names
+ * The field names to remove from the entity normalization.
+ *
+ * @return array
+ * The updated entity normalization.
+ *
+ * @see ::testPatch
+ */
+ protected function removeFieldsFromNormalization(array $normalization, $field_names) {
+ return array_diff_key($normalization, array_flip($field_names));
+ }
+
+ /**
+ * Asserts a 406 response… or in some cases a 403 response, because weirdness.
+ *
+ * Asserting a 406 response should be easy, but it's not, due to bugs.
+ *
+ * Drupal returns a 403 response instead of a 406 response when:
+ * - there is a canonical route, i.e. one that serves HTML
+ * - unless the user is logged in with any non-global authentication provider,
+ * because then they tried to access a route that requires the user to be
+ * authenticated, but they used an authentication provider that is only
+ * accepted for specific routes, and HTML routes never have such specific
+ * authentication providers specified. (By default, only 'cookie' is a
+ * global authentication provider.)
+ *
+ * @todo Remove this in https://www.drupal.org/node/2805279.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The response to assert.
+ */
+ protected function assert406Response(ResponseInterface $response) {
+ if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
+ $this->assertSame(403, $response->getStatusCode());
+ }
+ else {
+ // This is the desired response.
+ $this->assertSame(406, $response->getStatusCode());
+ }
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php
new file mode 100644
index 0000000..a7e4420
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php
@@ -0,0 +1,29 @@
+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() {
+ $entity_test = EntityTest::create([
+ 'name' => 'Llama',
+ 'type' => 'entity_test',
+ ]);
+ $entity_test->setOwnerId(0);
+ $entity_test->save();
+
+ return $entity_test;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load(0);
+ $normalization = [
+ 'uuid' => [
+ [
+ 'value' => $this->entity->uuid()
+ ]
+ ],
+ 'id' => [
+ [
+ 'value' => '1',
+ ],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'type' => [
+ [
+ 'value' => 'entity_test',
+ ]
+ ],
+ 'name' => [
+ [
+ 'value' => 'Llama',
+ ]
+ ],
+ 'created' => [
+ [
+ 'value' => $this->entity->get('created')->value,
+ ]
+ ],
+ 'user_id' => [
+ [
+ 'target_id' => $author->id(),
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => $author->toUrl()->toString(),
+ ]
+ ],
+ 'field_test_text' => [],
+ ];
+
+ return $normalization;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'type' => 'entity_test',
+ 'name' => [
+ [
+ 'value' => 'Dramallama',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php
new file mode 100644
index 0000000..24d47c4
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php
@@ -0,0 +1,29 @@
+grantPermissionsToTestedRole(['access content']);
+ break;
+ case 'POST':
+ $this->grantPermissionsToTestedRole(['access content', 'create camelids content']);
+ break;
+ case 'PATCH':
+ $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(static::$auth ? $this->account->id() : 0)
+ ->setPublished(TRUE)
+ ->setCreatedTime(123456789)
+ ->setChangedTime(123456789)
+ ->setRevisionCreationTime(123456789)
+ ->save();
+
+ return $node;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load($this->entity->getOwnerId());
+ return [
+ 'nid' => [
+ ['value' => 1],
+ ],
+ 'uuid' => [
+ ['value' => $this->entity->uuid()],
+ ],
+ 'vid' => [
+ ['value' => 1],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'type' => [
+ [
+ 'target_id' => 'camelids',
+ 'target_type' => 'node_type',
+ 'target_uuid' => NodeType::load('camelids')->uuid(),
+ ],
+ ],
+ 'title' => [
+ [
+ 'value' => 'Llama',
+ ],
+ ],
+ 'status' => [
+ [
+ 'value' => 1,
+ ],
+ ],
+ 'created' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'changed' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'promote' => [
+ [
+ 'value' => 1,
+ ],
+ ],
+ 'sticky' => [
+ [
+ 'value' => '0',
+ ],
+ ],
+ 'revision_timestamp' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'revision_translation_affected' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ 'default_langcode' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ 'uid' => [
+ [
+ 'target_id' => $author->id(),
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/' . $author->id(),
+ ],
+ ],
+ 'revision_uid' => [
+ [
+ 'target_id' => $author->id(),
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/' . $author->id(),
+ ],
+ ],
+ 'revision_log' => [
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'type' => [
+ [
+ 'target_id' => 'camelids',
+ ],
+ ],
+ 'title' => [
+ [
+ 'value' => 'Dramallama',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php
new file mode 100644
index 0000000..3ec96eb
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php
@@ -0,0 +1,29 @@
+assertSame(401, $response->getStatusCode());
+ $this->assertSame('{"message":"A fatal error occurred: No authentication credentials provided."}', (string) $response->getBody());
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php
new file mode 100644
index 0000000..3aa4149
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php
@@ -0,0 +1,36 @@
+grantPermissionsToTestedRole(['administer permissions']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function createEntity() {
+ $role = Role::create([
+ 'id' => 'llama',
+ 'name' => $this->randomString(),
+ ]);
+ $role->save();
+
+ return $role;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ return [
+ '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/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php
new file mode 100644
index 0000000..6e01c03
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php
@@ -0,0 +1,29 @@
+grantPermissionsToTestedRole(['access content']);
+ break;
+ case 'POST':
+ case 'PATCH':
+ case 'DELETE':
+ // @todo Update once https://www.drupal.org/node/2824408 lands.
+ $this->grantPermissionsToTestedRole(['administer taxonomy']);
+ 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')
+ ->setChangedTime(123456789);
+ $term->save();
+
+ return $term;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ return [
+ 'tid' => [
+ ['value' => 1],
+ ],
+ 'uuid' => [
+ ['value' => $this->entity->uuid()],
+ ],
+ 'vid' => [
+ [
+ 'target_id' => 'camelids',
+ 'target_type' => 'taxonomy_vocabulary',
+ 'target_uuid' => Vocabulary::load('camelids')->uuid(),
+ ],
+ ],
+ 'name' => [
+ ['value' => 'Llama'],
+ ],
+ 'description' => [
+ [
+ 'value' => NULL,
+ 'format' => NULL,
+ ],
+ ],
+ 'parent' => [],
+ 'weight' => [
+ ['value' => 0],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'changed' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'default_langcode' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'vid' => [
+ [
+ 'target_id' => 'camelids',
+ ],
+ ],
+ 'name' => [
+ [
+ 'value' => 'Dramallama',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php
new file mode 100644
index 0000000..a20aff8
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php
@@ -0,0 +1,29 @@
+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() {
+ return [
+ 'uid' => [
+ ['value' => '3'],
+ ],
+ 'uuid' => [
+ ['value' => $this->entity->uuid()],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'name' => [
+ [
+ 'value' => 'Llama',
+ ],
+ ],
+ 'created' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'changed' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'default_langcode' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return [
+ 'name' => [
+ [
+ 'value' => 'Dramallama ' . $this->randomMachineName(),
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Tests PATCHing security-sensitive base fields of the logged in account.
+ */
+ public function testPatchDxForSecuritySensitiveBaseFields() {
+ // The anonymous user is never allowed to modify itself.
+ if (!static::$auth) {
+ $this->markTestSkipped();
+ }
+
+ $this->initAuthentication();
+ $this->provisionEntityResource();
+ $this->setUpAuthorization('PATCH');
+
+ /** @var \Drupal\user\UserInterface $user */
+ $user = static::$auth ? $this->account : User::load(0);
+ $original_normalization = array_diff_key($this->serializer->normalize($user, static::$format), ['changed' => TRUE]);
+
+
+ // Since this test must be performed by the user that is being modified,
+ // we cannot use $this->getUrl().
+ $url = $user->toUrl()->setOption('query', ['_format' => static::$format]);
+ $request_options = [
+ RequestOptions::HEADERS => ['Content-Type' => static::$mimeType],
+ ];
+ $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+ // Test case 1: changing email.
+ $normalization = $original_normalization;
+ $normalization['mail'] = [['value' => 'new-email@example.com']];
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // DX: 422 when changing email without providing the password.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response);
+ $this->assertSame(422, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n"], static::$format), (string) $response->getBody());
+
+
+ $normalization['pass'] = [['existing' => 'wrong']];
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+ // DX: 422 when changing email while providing a wrong password.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response);
+ $this->assertSame(422, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n"], static::$format), (string) $response->getBody());
+
+
+ $normalization['pass'] = [['existing' => $this->account->passRaw]];
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+
+
+ // Test case 2: changing password.
+ $normalization = $original_normalization;
+ $new_password = $this->randomString();
+ $normalization['pass'] = [['value' => $new_password]];
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // DX: 422 when changing password without providing the current password.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the Password.\n", $response);
+ $this->assertSame(422, $response->getStatusCode());
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the Password.\n"], static::$format), (string) $response->getBody());
+
+
+ $normalization['pass'][0]['existing'] = $this->account->pass_raw;
+ $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
+
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+
+
+ // Verify that we can log in with the new password.
+ $request_body = [
+ 'name' => $user->getAccountName(),
+ 'pass' => $new_password,
+ ];
+ $request_options = [
+ RequestOptions::HEADERS => [],
+ RequestOptions::BODY => $this->serializer->encode($request_body, 'json'),
+ ];
+ $response = $this->httpClient->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json')->toString(), $request_options);
+ $this->assertSame(200, $response->getStatusCode());
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php
new file mode 100644
index 0000000..4e29106
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php
@@ -0,0 +1,37 @@
+markTestSkipped();
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php
new file mode 100644
index 0000000..1060050
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php
@@ -0,0 +1,45 @@
+grantPermissionsToTestedRole(['administer taxonomy']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function createEntity() {
+ $vocabulary = Vocabulary::create([
+ 'name' => 'Llama',
+ 'vid' => 'llama',
+ ]);
+ $vocabulary->save();
+
+ return $vocabulary;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ return [
+ '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.
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php
new file mode 100644
index 0000000..495bf5a
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php
@@ -0,0 +1,25 @@
+assertSame(401, $response->getStatusCode());
+ $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+ // Note that strange 'A fatal error occurred: ' prefix, that should not
+ // exist.
+ // @todo Fix in https://www.drupal.org/node/2805281.
+ $this->assertSame('{"message":"A fatal error occurred: No authentication credentials provided."}', (string) $response->getBody());
+ }
+
+}
diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
new file mode 100644
index 0000000..4822fa1
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,353 @@
+getPermissions() as $permission) {
+ $user_role->revokePermission($permission);
+ }
+ $user_role->save();
+ assert('[] === $user_role->getPermissions()', 'The anonymous user role has no permissions at all.');
+
+ if (static::$auth !== FALSE) {
+ // Ensure the authenticated user role has no permissions at all.
+ $user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
+ foreach ($user_role->getPermissions() as $permission) {
+ $user_role->revokePermission($permission);
+ }
+ $user_role->save();
+ assert('[] === $user_role->getPermissions()', 'The authenticated user role has no permissions at all.');
+
+ // Create an account.
+ $this->account = $this->createUser();
+ }
+ else {
+ // Otherwise, also create an account, so that any test involving User
+ // entities will have the same user IDs regardless of authentication.
+ $this->createUser();
+ }
+
+ $this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config');
+
+ // Ensure there's a clean slate: delete all REST resource config entities.
+ $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple());
+ }
+
+ /**
+ * Provisions a REST resource.
+ *
+ * @param string $resource_type
+ * The resource type (REST resource plugin ID).
+ * @param string[] $formats
+ * The allowed formats for this resource.
+ * @param string[] $authentication
+ * The allowed authentication providers for this resource.
+ */
+ protected function provisionResource($resource_type, $formats = [], $authentication = []) {
+ $this->resourceConfigStorage->create([
+ 'id' => $resource_type,
+ 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+ 'configuration' => [
+ 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'],
+ 'formats' => $formats,
+ 'authentication' => $authentication,
+ ]
+ ])->save();
+ // @todo Remove this in https://www.drupal.org/node/2815845.
+ drupal_flush_all_caches();
+ }
+
+ /**
+ * Sets up the necessary authorization.
+ *
+ * In case of a test verifying publicly accessible REST resources: grant
+ * permissions to the anonymous user role.
+ *
+ * In case of a test verifying behavior when using a particular authentication
+ * provider: create a user with a particular set of permissions.
+ *
+ * Because of the $method parameter, it's possible to first set up
+ * authentication for only GET, then add POST, et cetera. This then also
+ * allows for verifying a 403 in case of missing authorization.
+ *
+ * @param string $method
+ * The HTTP method for which to set up authentication.
+ *
+ * @return void
+ *
+ * @see ::grantPermissionsToAnonymousRole()
+ * @see ::grantPermissionsToAuthenticatedRole()
+ */
+ abstract protected function setUpAuthorization($method);
+
+ /**
+ * Verifies the error response in case of missing authentication.
+ *
+ * @return void
+ */
+ abstract protected function assertResponseWhenMissingAuthentication(ResponseInterface $response);
+
+ /**
+ * Asserts normalization-specific edge cases.
+ *
+ * (Should be called before sending a well-formed request.)
+ *
+ * @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.
+ */
+ abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options);
+
+ /**
+ * Asserts authentication provider-specific edge cases.
+ *
+ * (Should be called before sending a well-formed request.)
+ *
+ * @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.
+ */
+ abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options);
+
+ /**
+ * Initializes authentication.
+ *
+ * E.g. for cookie authentication, we first need to get a cookie.
+ */
+ protected function initAuthentication() {}
+
+ /**
+ * 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 [];
+ }
+
+ /**
+ * Grants permissions to the anonymous role.
+ *
+ * @param string[] $permissions
+ * Permissions to grant.
+ */
+ protected function grantPermissionsToAnonymousRole(array $permissions) {
+ $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions);
+ }
+
+ /**
+ * Grants permissions to the authenticated role.
+ *
+ * @param string[] $permissions
+ * Permissions to grant.
+ */
+ protected function grantPermissionsToAuthenticatedRole(array $permissions) {
+ $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
+ }
+
+ /**
+ * Grants permissions to the tested role: anonymous or authenticated.
+ *
+ * @param string[] $permissions
+ * Permissions to grant.
+ *
+ * @see ::grantPermissionsToAuthenticatedRole()
+ * @see ::grantPermissionsToAnonymousRole()
+ */
+ protected function grantPermissionsToTestedRole(array $permissions) {
+ if (static::$auth) {
+ $this->grantPermissionsToAuthenticatedRole($permissions);
+ }
+ else {
+ $this->grantPermissionsToAnonymousRole($permissions);
+ }
+ }
+
+ /**
+ * Performs a HTTP request. Wraps the Guzzle HTTP client.
+ *
+ * Why wrap the Guzzle HTTP client? Because 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.
+ *
+ * @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;
+ return $this->httpClient->request($method, $url->toString(), $request_options);
+ }
+
+ /**
+ * Asserts that a resource response has the given status code and body.
+ *
+ * (Also asserts that the expected error MIME type is present, but this is
+ * defined globally for the test via static::$expectedErrorMimeType, because
+ * all error responses should use the same MIME type.)
+ *
+ * @param int $expected_status_code
+ * The expected response status.
+ * @param string|false $expected_body
+ * The expected response body. FALSE in case this should not be asserted.
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The response to assert.
+ */
+ protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response) {
+ $this->assertSame($expected_status_code, $response->getStatusCode());
+ if ($expected_status_code < 400) {
+ $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
+ }
+ else {
+ $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+ }
+ if ($expected_body !== FALSE) {
+ $this->assertSame($expected_body, (string) $response->getBody());
+ }
+ }
+
+ /**
+ * Asserts that a resource error response has the given message.
+ *
+ * (Also asserts that the expected error MIME type is present, but this is
+ * defined globally for the test via static::$expectedErrorMimeType, because
+ * all error responses should use the same MIME type.)
+ *
+ * @param int $expected_status_code
+ * The expected response status.
+ * @param string $expected_message
+ * The expected error message.
+ * @param \Psr\Http\Message\ResponseInterface $response
+ * The error response to assert.
+ */
+ protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) {
+ // @todo Fix this in https://www.drupal.org/node/2813755.
+ $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT];
+ $expected_body = $this->serializer->encode(['message' => $expected_message], static::$format, $encode_options);
+ $this->assertResourceResponse($expected_status_code, $expected_body, $response);
+ }
+
+}