.../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 | 130 +++ .../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 | 26 + .../config_test_rest/config_test_rest.info.yml | 7 + .../config_test_rest/config_test_rest.module | 30 + .../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 | 45 + .../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 | 45 + .../EntityResource/Node/NodeJsonCookieTest.php | 34 + .../EntityResource/Node/NodeResourceTestBase.php | 196 ++++ .../EntityResource/Role/RoleJsonAnonTest.php | 29 + .../EntityResource/Role/RoleJsonBasicAuthTest.php | 48 + .../EntityResource/Role/RoleJsonCookieTest.php | 34 + .../EntityResource/Role/RoleResourceTestBase.php | 69 ++ .../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 | 69 ++ .../JsonBasicAuthWorkaroundFor2805281Trait.php | 25 + .../rest/tests/src/Functional/ResourceTestBase.php | 349 +++++++ 76 files changed, 5554 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. + // @codingStandardsIgnoreStart +/* + $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()); +*/ + // @codingStandardsIgnoreEnd + + 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..fcd9979 --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module @@ -0,0 +1,30 @@ +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..d944c61 --- /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..9f4ec07 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php @@ -0,0 +1,34 @@ +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..f1f0458 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -0,0 +1,349 @@ +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. + * + * @see ::grantPermissionsToAnonymousRole() + * @see ::grantPermissionsToAuthenticatedRole() + */ + abstract protected function setUpAuthorization($method); + + /** + * Verifies the error response in case of missing authentication. + */ + 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); + } + +}