.../EntityResource/Block/BlockHalJsonAnonTest.php | 35 +
.../Block/BlockHalJsonBasicAuthTest.php | 46 ++
.../Block/BlockHalJsonCookieTest.php | 40 +
.../Comment/CommentHalJsonAnonTest.php | 118 +++
.../Comment/CommentHalJsonBasicAuthTest.php | 30 +
.../Comment/CommentHalJsonCookieTest.php | 19 +
.../ConfigTest/ConfigTestHalJsonAnonTest.php | 35 +
.../ConfigTest/ConfigTestHalJsonBasicAuthTest.php | 46 ++
.../ConfigTest/ConfigTestHalJsonCookieTest.php | 40 +
.../EntityTest/EntityTestHalJsonAnonTest.php | 96 +++
.../EntityTest/EntityTestHalJsonBasicAuthTest.php | 30 +
.../EntityTest/EntityTestHalJsonCookieTest.php | 19 +
.../EntityResource/HalEntityNormalizationTrait.php | 54 ++
.../EntityResource/Node/NodeHalJsonAnonTest.php | 116 +++
.../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 | 70 ++
.../Term/TermHalJsonBasicAuthTest.php | 30 +
.../EntityResource/Term/TermHalJsonCookieTest.php | 18 +
.../EntityResource/User/UserHalJsonAnonTest.php | 70 ++
.../User/UserHalJsonBasicAuthTest.php | 30 +
.../EntityResource/User/UserHalJsonCookieTest.php | 19 +
.../Vocabulary/VocabularyHalJsonAnonTest.php | 43 ++
.../Vocabulary/VocabularyHalJsonBasicAuthTest.php | 46 ++
.../Vocabulary/VocabularyHalJsonCookieTest.php | 40 +
.../HalJsonBasicAuthWorkaroundFor2805281Trait.php | 24 +
.../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 | 29 +-
.../tests/src/Functional/AnonResourceTestTrait.php | 22 +
.../src/Functional/BasicAuthResourceTestTrait.php | 36 +
.../src/Functional/CookieResourceTestTrait.php | 115 +++
.../EntityResource/Block/BlockJsonAnonTest.php | 29 +
.../Block/BlockJsonBasicAuthTest.php | 45 ++
.../EntityResource/Block/BlockJsonCookieTest.php | 34 +
.../EntityResource/Block/BlockResourceTestBase.php | 111 +++
.../EntityResource/Comment/CommentJsonAnonTest.php | 29 +
.../Comment/CommentJsonBasicAuthTest.php | 46 ++
.../Comment/CommentJsonCookieTest.php | 34 +
.../Comment/CommentResourceTestBase.php | 266 +++++++
.../ConfigTest/ConfigTestJsonAnonTest.php | 29 +
.../ConfigTest/ConfigTestJsonBasicAuthTest.php | 45 ++
.../ConfigTest/ConfigTestJsonCookieTest.php | 34 +
.../ConfigTest/ConfigTestResourceTestBase.php | 72 ++
.../EntityResource/EntityResourceTestBase.php | 849 +++++++++++++++++++++
.../EntityTest/EntityTestJsonAnonTest.php | 29 +
.../EntityTest/EntityTestJsonBasicAuthTest.php | 46 ++
.../EntityTest/EntityTestJsonCookieTest.php | 34 +
.../EntityTest/EntityTestResourceTestBase.php | 122 +++
.../EntityResource/Node/NodeJsonAnonTest.php | 29 +
.../EntityResource/Node/NodeJsonBasicAuthTest.php | 46 ++
.../EntityResource/Node/NodeJsonCookieTest.php | 34 +
.../EntityResource/Node/NodeResourceTestBase.php | 182 +++++
.../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 | 133 ++++
.../EntityResource/User/UserJsonAnonTest.php | 29 +
.../EntityResource/User/UserJsonBasicAuthTest.php | 45 ++
.../EntityResource/User/UserJsonCookieTest.php | 34 +
.../EntityResource/User/UserResourceTestBase.php | 122 +++
.../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 | 336 ++++++++
75 files changed, 4865 insertions(+), 4 deletions(-)
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);
+
+ $author = User::load(0);
+ $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/0?_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/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/comment/comment',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php
new file mode 100644
index 0000000..6b42f3b
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php
@@ -0,0 +1,30 @@
+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',
+ ],
+ ],
+ ];
+ }
+
+}
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->getName() === $bundle_key) ? FALSE : $field instanceof EntityReferenceFieldItemListInterface;
+ }));
+ foreach ($reference_fields as $field_name) {
+ unset($normalization[$field_name]);
+ }
+
+ // In the HAL normalization, the bundle field omits the 'target_type' and
+ // 'target_uuid' properties, because it's encoded in the '_links' section.
+ if ($bundle_key) {
+ unset($normalization[$bundle_key][0]['target_type']);
+ unset($normalization[$bundle_key][0]['target_uuid']);
+ }
+
+ // In the HAL normalization, empty fields are omitted.
+ $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;
+ }
+
+}
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..29c048a
--- /dev/null
+++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php
@@ -0,0 +1,116 @@
+applyHalFieldNormalization($default_normalization);
+
+ $author = User::load(0);
+ return $normalization + [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/node/1?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/node/camelids',
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+ [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ 'lang' => 'en',
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+ [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ ],
+ ],
+ ],
+ '_embedded' => [
+ $this->baseUrl . '/rest/relation/node/camelids/uid' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ 'lang' => 'en',
+ ],
+ ],
+ $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [
+ [
+ '_links' => [
+ 'self' => [
+ 'href' => $this->baseUrl . '/user/0?_format=hal_json',
+ ],
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/user/user',
+ ],
+ ],
+ 'uuid' => [
+ ['value' => $author->uuid()]
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getNormalizedPostEntity() {
+ return parent::getNormalizedPostEntity() + [
+ '_links' => [
+ 'type' => [
+ 'href' => $this->baseUrl . '/rest/type/node/camelids',
+ ],
+ ],
+ ];
+ }
+
+}
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',
+ ],
+ ],
+ ];
+ }
+
+}
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',
+ ],
+ ],
+ ];
+ }
+
+}
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..78600b9 100644
--- a/core/modules/rest/tests/modules/rest_test/rest_test.module
+++ b/core/modules/rest/tests/modules/rest_test/rest_test.module
@@ -1,9 +1,9 @@
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();
+}
\ No newline at end of file
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..3e1a912
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php
@@ -0,0 +1,22 @@
+ [
+ '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..c085247
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php
@@ -0,0 +1,115 @@
+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;
+ if (!in_array($method, ['HEAD', 'GET'])) {
+ $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 HTTP methods. No need
+ // for additional assertions.
+ if (in_array($method, ['HEAD', 'GET'])) {
+ 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 Investigate further.
+ $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 when POSTing config entity support is added in https://www.drupal.org/node/2300677.
+ }
+
+}
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..47478aa
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php
@@ -0,0 +1,29 @@
+grantPermissionsToTestedRole(['access comments', 'view test entity']);
+ break;
+ case 'POST':
+ $this->grantPermissionsToTestedRole(['post comments']);
+ break;
+ case 'PATCH':
+ 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')
+ ->setPublished(TRUE)
+ ->setCreatedTime(123456789)
+ ->setChangedTime(123456789);
+ $comment->save();
+
+ return $comment;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load(0);
+ 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' => '0',
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/0',
+ ],
+ ],
+ '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]);
+ }
+
+ 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 Remove the first line in favor of the commented 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 Remove the first line in favor of the commented 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 = entity_create('config_test', [
+ '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 when POSTing config entity support is added 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..18a61c9
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -0,0 +1,849 @@
+provisionResource('entity.' . static::$entityType, [static::$format], $auth);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setUp() {
+ parent::setUp();
+
+ $this->serializer = $this->container->get('serializer');
+
+ // Set up a HTTP client that accepts relative URLs.
+ $this->httpClient = $this->container->get('http_client_factory')
+ ->fromOptions(['base_uri' => $this->baseUrl]);
+
+ // Create an entity.
+ $this->entity = $this->createEntity();
+
+ if ($this->entity instanceof FieldableEntityInterface) {
+ // Add access-protected field.
+ FieldStorageConfig::create([
+ 'entity_type' => static::$entityType,
+ 'field_name' => 'field_rest_test',
+ 'type' => 'text',
+ ])
+ ->setCardinality(1)
+ ->save();
+ FieldConfig::create([
+ 'entity_type' => static::$entityType,
+ 'field_name' => 'field_rest_test',
+ 'bundle' => $this->entity->bundle(),
+ ])
+ ->setLabel('Test field')
+ ->setTranslatable(FALSE)
+ ->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();
+ }
+
+ 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.
+ $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.
+ $response = $this->request('GET', $url, $request_options);
+ // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+ 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 = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('GET'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('GET', $url, $request_options);
+ // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+ $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'));
+ }
+ // Comparing the exact serialization is pointless, because the order of
+ // fields does not matter (at least not yet). That's why we only compare the
+ // normalized entity with the decoded response: it's comparing PHP arrays
+ // instead of strings.
+ $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string)$response->getBody(), static::$format));
+ // Not only assert the normalization, also assert deserialization of the
+ // response results in the expected object.
+ $unserialized = $this->serializer->deserialize((string)$response->getBody(), get_class($this->entity), static::$format);
+ $this->assertSame($unserialized->uuid(), $this->entity->uuid());
+ $get_headers = $response->getHeaders();
+
+ // Verify that the GET and HEAD responses are the same, that the only
+ // difference is that there's no body.
+ $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache'];
+ foreach ($ignored_headers as $ignored_header) {
+ unset($head_headers[$ignored_header]);
+ unset($get_headers[$ignored_header]);
+ }
+ $this->assertSame($get_headers, $head_headers);
+
+
+ $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);
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityType]);
+
+
+ // 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);
+ // @todo this works fine locally, but on testbot it comes back with 'text/plain; charset=UTF-8'. WTF.
+// $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+
+
+ $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
+
+
+ // DX: 406 when requesting unsupported format but specifying Accept header.
+ $response = $this->request('GET', $url, $request_options);
+ $this->assert406Response($response);
+ $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type'));
+
+
+ $url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format);
+ $url->setRouteParameter(static::$entityType, 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::$entityType . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
+ $message = 'The "' . static::$entityType . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityType . '.GET.' . static::$format . '")';
+ $this->assertResourceErrorResponse(404, $message, $response);
+ }
+
+ /**
+ * 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::$entityType . '/' . $this->entity->id());
+ }
+
+ 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] when https://www.drupal.org/node/2820743 lands.
+ $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' => 'no access value']]], 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);
+ // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+ $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->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('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 use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+// $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 = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('POST'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('POST', $url, $request_options);
+ // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+ $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::$labelField;
+ $label_field_capitalized = ucfirst($label_field);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $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 use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $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 once https://www.drupal.org/node/2821013 is fixed.
+ $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 authentication provider's
+ // edge cases to also be tested.
+ $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
+
+ // 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->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);
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityType]);
+
+
+ // 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'));
+ }
+
+ 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);
+ // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+ $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 use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands.
+// $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 = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('PATCH', $url, $request_options);
+ // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+ $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::$labelField;
+ $label_field_capitalized = ucfirst($label_field);
+ // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands.
+// $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);
+
+
+ $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
+
+
+ // Before sending a well-formed request, allow the authentication provider's
+ // edge cases to also be tested.
+ $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+
+
+ $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);
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityType]);
+
+
+ // 200 for well-formed request.
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertResourceResponse(200, FALSE, $response);
+ }
+
+ 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);
+ // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'.
+ $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 = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
+
+
+ // DX: 403 when unauthorized.
+ $response = $this->request('DELETE', $url, $request_options);
+ // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands.
+ $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->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);
+ $this->assertResourceErrorResponse(403, '', $response);
+
+
+ $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityType]);
+
+
+ // 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());
+ }
+
+ /**
+ * 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::$entityType);
+ }
+
+ /**
+ * 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::$labelField;
+ $normalization[$label_field][1]['value'] = 'Second Title';
+
+ return $normalization;
+ }
+
+ /**
+ * 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(array(
+ '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')
+ ->setPublished(TRUE)
+ ->setCreatedTime(123456789)
+ ->setChangedTime(123456789)
+ ->setRevisionCreationTime(123456789)
+ ->save();
+
+ return $node;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getExpectedNormalizedEntity() {
+ $author = User::load(0);
+ return [
+ 'nid' => [
+ ['value' => 1],
+ ],
+ 'uuid' => [
+ ['value' => $this->entity->uuid()],
+ ],
+ 'vid' => [
+ ['value' => 1],
+ ],
+ 'langcode' => [
+ [
+ 'value' => 'en',
+ ],
+ ],
+ 'type' => [
+ [
+ 'target_id' => 'camelids',
+ 'target_type' => 'node_type',
+ 'target_uuid' => NodeType::load('camelids')->uuid(),
+ ],
+ ],
+ 'title' => [
+ [
+ 'value' => 'Llama',
+ ],
+ ],
+ 'status' => [
+ [
+ 'value' => 1,
+ ],
+ ],
+ 'created' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'changed' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'promote' => [
+ [
+ 'value' => 1,
+ ],
+ ],
+ 'sticky' => [
+ [
+ 'value' => '0',
+ ],
+ ],
+ 'revision_timestamp' => [
+ [
+ 'value' => '123456789',
+ ],
+ ],
+ 'revision_translation_affected' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ 'default_langcode' => [
+ [
+ 'value' => TRUE,
+ ],
+ ],
+ 'uid' => [
+ [
+ 'target_id' => '0',
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/0',
+ ],
+ ],
+ 'revision_uid' => [
+ [
+ 'target_id' => '0',
+ 'target_type' => 'user',
+ 'target_uuid' => $author->uuid(),
+ 'url' => base_path() . 'user/0',
+ ],
+ ],
+ 'revision_log' => [
+ ],
+ ];
+ }
+
+ /**
+ * {@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 when POSTing config entity support is added 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 Create issue similar to https://www.drupal.org/node/2808217.
+ $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(),
+ ],
+ ],
+ ];
+ }
+
+}
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 when POSTing config entity support is added 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..c392f0c
--- /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..549ebad
--- /dev/null
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -0,0 +1,336 @@
+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 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 Either add this to \Drupal\serialization\Encoder\JsonEncoder, or
+ // figure out how to let tests specify encoder options, and figure out
+ // whether they should apply to just error responses or to everything.
+ $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT];
+ $expected_body = $this->serializer->encode(['message' => $expected_message], static::$format, $encode_options);
+ $this->assertResourceResponse($expected_status_code, $expected_body, $response);
+ }
+
+}