.../EntityResource/Block/BlockHalJsonAnonTest.php | 35 ++ .../Block/BlockHalJsonBasicAuthTest.php | 46 ++ .../Comment/CommentHalJsonAnonTest.php | 118 +++++ .../Comment/CommentHalJsonBasicAuthTest.php | 30 ++ .../ConfigTest/ConfigTestHalJsonAnonTest.php | 35 ++ .../ConfigTest/ConfigTestHalJsonBasicAuthTest.php | 46 ++ .../EntityTest/EntityTestHalJsonAnonTest.php | 96 ++++ .../EntityTest/EntityTestHalJsonBasicAuthTest.php | 30 ++ .../EntityResource/HalEntityNormalizationTrait.php | 56 +++ .../EntityResource/Node/NodeHalJsonAnonTest.php | 116 +++++ .../Node/NodeHalJsonBasicAuthTest.php | 30 ++ .../EntityResource/Role/RoleHalJsonAnonTest.php | 35 ++ .../Role/RoleHalJsonBasicAuthTest.php | 46 ++ .../EntityResource/Term/TermHalJsonAnonTest.php | 70 +++ .../Term/TermHalJsonBasicAuthTest.php | 30 ++ .../EntityResource/User/UserHalJsonAnonTest.php | 70 +++ .../User/UserHalJsonBasicAuthTest.php | 30 ++ .../Vocabulary/VocabularyHalJsonAnonTest.php | 43 ++ .../Vocabulary/VocabularyHalJsonBasicAuthTest.php | 46 ++ .../HalJsonBasicAuthWorkaroundFor2805281Trait.php | 24 + .../config_test_rest/config_test_rest.info.yml | 7 + .../config_test_rest/config_test_rest.module | 26 + .../config_test_rest.permissions.yml | 2 + .../tests/src/Functional/AnonResourceTestTrait.php | 16 + .../src/Functional/BasicAuthResourceTestTrait.php | 30 ++ .../EntityResource/Block/BlockJsonAnonTest.php | 29 ++ .../Block/BlockJsonBasicAuthTest.php | 45 ++ .../EntityResource/Block/BlockResourceTestBase.php | 97 ++++ .../EntityResource/Comment/CommentJsonAnonTest.php | 29 ++ .../Comment/CommentJsonBasicAuthTest.php | 46 ++ .../Comment/CommentResourceTestBase.php | 250 ++++++++++ .../ConfigTest/ConfigTestJsonAnonTest.php | 29 ++ .../ConfigTest/ConfigTestJsonBasicAuthTest.php | 45 ++ .../ConfigTest/ConfigTestResourceTestBase.php | 61 +++ .../EntityResource/EntityResourceTestBase.php | 539 +++++++++++++++++++++ .../EntityTest/EntityTestJsonAnonTest.php | 29 ++ .../EntityTest/EntityTestJsonBasicAuthTest.php | 46 ++ .../EntityTest/EntityTestResourceTestBase.php | 114 +++++ .../EntityResource/Node/NodeJsonAnonTest.php | 29 ++ .../EntityResource/Node/NodeJsonBasicAuthTest.php | 46 ++ .../EntityResource/Node/NodeResourceTestBase.php | 170 +++++++ .../EntityResource/Role/RoleJsonAnonTest.php | 29 ++ .../EntityResource/Role/RoleJsonBasicAuthTest.php | 48 ++ .../EntityResource/Role/RoleResourceTestBase.php | 59 +++ .../EntityResource/Term/TermJsonAnonTest.php | 29 ++ .../EntityResource/Term/TermJsonBasicAuthTest.php | 45 ++ .../EntityResource/Term/TermResourceTestBase.php | 124 +++++ .../EntityResource/User/UserJsonAnonTest.php | 29 ++ .../EntityResource/User/UserJsonBasicAuthTest.php | 45 ++ .../EntityResource/User/UserResourceTestBase.php | 116 +++++ .../Vocabulary/VocabularyJsonAnonTest.php | 37 ++ .../Vocabulary/VocabularyJsonBasicAuthTest.php | 46 ++ .../Vocabulary/VocabularyResourceTestBase.php | 61 +++ .../JsonBasicAuthWorkaroundFor2805281Trait.php | 25 + .../rest/tests/src/Functional/ResourceTestBase.php | 315 ++++++++++++ 55 files changed, 3725 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); + + $author = User::load(0); + $commented_entity = EntityTest::load(1); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/comment/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/comment/comment', + ], + $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [ + [ + 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json', + ], + ], + $this->baseUrl . '/rest/relation/comment/comment/uid' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + 'lang' => 'en', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/bar', + ], + ], + 'uuid' => [ + ['value' => $commented_entity->uuid()] + ], + ], + ], + $this->baseUrl . '/rest/relation/comment/comment/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/comment/comment', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php new file mode 100644 index 0000000..9461f23 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +applyHalFieldNormalization($default_normalization); + + $author = User::load(0); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test', + ], + $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + 'lang' => 'en', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/entity_test/entity_test/user_id' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php new file mode 100644 index 0000000..063e1e3 --- /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) { + if ($field->getName() === $bundle_key) { + return FALSE; + } + return $field instanceof EntityReferenceFieldItemListInterface; + })); + foreach ($reference_fields as $field_name) { + unset($normalization[$field_name]); + } + + // In the HAL normalization, the bundle field omits the 'target_type' and + // 'target_uuid' properties, because it's encoded in the '_links' section. + if ($bundle_key) { + unset($normalization[$bundle_key][0]['target_type']); + unset($normalization[$bundle_key][0]['target_uuid']); + } + + // In the HAL normalization, empty fields are omitted. + foreach ($normalization as $field_name => $data) { + if ($this->entity->$field_name->isEmpty()) { + unset($normalization[$field_name]); + } + } + + return $normalization; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php new file mode 100644 index 0000000..5fe42d3 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -0,0 +1,116 @@ +applyHalFieldNormalization($default_normalization); + + $author = User::load(0); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/node/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php new file mode 100644 index 0000000..1132ee0 --- /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 getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php new file mode 100644 index 0000000..59b754a --- /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 getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + ]; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php new file mode 100644 index 0000000..0ffef7f --- /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..03c9232 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +assertSame(401, $response->getStatusCode()); + // @todo this works fine locally, but on testbot it comes back with 'text/plain; charset=UTF-8'. WTF. +// $this->assertSame(['application/hal+json'], $response->getHeader('Content-Type')); + $this->assertSame('No authentication credentials provided.', (string)$response->getBody()); + } +} diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml new file mode 100644 index 0000000..cf9efee --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.info.yml @@ -0,0 +1,7 @@ +name: 'Configuration test REST' +type: module +package: Testing +version: VERSION +core: 8.x +dependencies: + - config_test diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module new file mode 100644 index 0000000..f7e24a2 --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module @@ -0,0 +1,26 @@ +hasPermission('view config_test'))->cachePerPermissions(); +} diff --git a/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml new file mode 100644 index 0000000..b8fd229 --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml @@ -0,0 +1,2 @@ +view config_test: + title: 'View ConfigTest entities' diff --git a/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php new file mode 100644 index 0000000..cc089de --- /dev/null +++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php @@ -0,0 +1,16 @@ + [ + 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response); + } + +} 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; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $block = Block::create([ + 'plugin' => 'llama_block', + 'region' => 'header', + 'id' => 'llama', + 'theme' => 'classy', + ]); + // All blocks can be viewed by the anonymous user by default. An interesting + // side effect of this is that any anonymous user is also able to read the + // corresponding block config entity via REST, even if an authentication + // provider is configured for the block config entity REST resource! In + // other words: Block entities do not distinguish between 'view' as in + // "render on a page" and 'view' as in "read the configuration". + // This prevents that. + // @todo Investigate further. + $block->setVisibilityConfig('user_role', [ + 'id' => 'user_role', + 'roles' => ['non-existing-role' => 'non-existing-role'], + 'negate' => FALSE, + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + ]); + $block->save(); + + return $block; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $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; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php new file mode 100644 index 0000000..47478aa --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php @@ -0,0 +1,29 @@ +grantPermissionsToTestedRole(['access comments', 'view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['post comments']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "bar" bundle for the "entity_test" entity type and create. + $bundle = 'bar'; + entity_test_create_bundle($bundle, NULL, 'entity_test'); + + // Create a comment field on this bundle. + $this->addDefaultCommentField('entity_test', 'bar', 'comment'); + + // Create a "Camelids" test entity that the comment will be assigned to. + $commented_entity = EntityTest::create(array( + 'name' => 'Camelids', + 'type' => 'bar', + )); + $commented_entity->save(); + + // Create a "Llama" comment. + $comment = Comment::create([ + 'comment_body' => [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'plain_text', + ], + 'entity_id' => $commented_entity->id(), + 'entity_type' => 'entity_test', + 'field_name' => 'comment', + ]); + $comment->setSubject('Llama') + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789); + $comment->save(); + + return $comment; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $author = User::load(0); + return [ + 'cid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'comment_type' => [ + [ + 'target_id' => 'comment', + 'target_type' => 'comment_type', + 'target_uuid' => CommentType::load('comment')->uuid(), + ], + ], + 'subject' => [ + [ + 'value' => 'Llama', + ], + ], + 'status' => [ + [ + 'value' => 1, + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + 'uid' => [ + [ + 'target_id' => '0', + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/0', + ], + ], + 'pid' => [], + 'entity_type' => [ + [ + 'value' => 'entity_test', + ], + ], + 'entity_id' => [ + [ + 'target_id' => '1', + 'target_type' => 'entity_test', + 'target_uuid' => EntityTest::load(1)->uuid(), + 'url' => base_path() . 'entity_test/1', + ], + ], + 'field_name' => [ + [ + 'value' => 'comment', + ], + ], + 'name' => [], + 'homepage' => [], + 'thread' => [ + [ + 'value' => '01/', + ], + ], + 'comment_body' => [ + [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'plain_text', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + 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', + ], + ], + ]; + } + + public function testPostDxWithoutCriticalBaseFields() { + $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()); + + // DX: 422 when missing 'entity_type' field. + $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedEntityToCreate(), ['entity_type' => TRUE]), static::$format); + $response = $this->request('POST', $url, $request_options); + // @todo Remove the first line in favor of the commented line in https://www.drupal.org/node/2820364. + $this->assertResourceErrorResponse(500, 'A fatal error occurred: Internal Server Error', $response); + //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response); + + // DX: 422 when missing 'entity_id' field. + $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedEntityToCreate(), ['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->getNormalizedEntityToCreate(), ['field_name' => TRUE]), static::$format); + $response = $this->request('POST', $url, $request_options); + // @todo Remove the first line in favor of the commented line in https://www.drupal.org/node/2820364. + $this->assertResourceErrorResponse(500, 'A fatal error occurred: Field is unknown.', $response); + //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nfield_name: This value should not be null.\n", $response); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php new file mode 100644 index 0000000..db79e6c --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php @@ -0,0 +1,29 @@ +grantPermissionsToTestedRole(['view config_test']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $config_test = entity_create('config_test', [ + 'id' => 'llama', + 'label' => 'Llama', + ]); + $config_test->save(); + + return $config_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $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; + } + +} 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..158385b --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -0,0 +1,539 @@ +provisionResource('entity.' . static::$entityType, [static::$format], $auth); + } + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + $this->serializer = $this->container->get('serializer'); + + // Set up a HTTP client that accepts relative URLs. + $this->httpClient = $this->container->get('http_client_factory') + ->fromOptions(['base_uri' => $this->baseUrl]); + + // Add field with specific allowed value. + // (allows testing invalid vs valid field value) + + // Add access-protected field to entity type. + // (allows testing with field that cannot be modified) + + // Create an entity. + $this->entity = $this->createEntity(); + + // @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(); + + public function testGet() { + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getUrl(); + $request_options = []; + + + // DX: 404 when resource not provisioned, 403 if canonical route. + $response = $this->request('GET', $url, $request_options); + $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when resource not provisioned, 403 if canonical route. + $response = $this->request('GET', $url, $request_options); + // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'. + if ($has_canonical_url) { + $this->assertResourceErrorResponse(403, '', $response); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $this->provisionEntityResource(); + // Simulate the developer again forgetting the ?_format query string. + $url->setOption('query', []); + + + + // DX: 406 when ?_format is missing, except when requesting a canonical HTML + // route. + $response = $this->request('GET', $url, $request_options); + if ($has_canonical_url && !static::$auth) { + $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->verifyResponseWhenMissingAuthentication($response); + } + + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions()); + + + // DX: 403 when unauthorized. + $response = $this->request('GET', $url, $request_options); + // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('GET'); + + + // 200 for well-formed HEAD request. + $response = $this->request('HEAD', $url, $request_options); + $this->assertResourceResponse(200, '', $response); + if (!$this->account) { + $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache')); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + $head_headers = $response->getHeaders(); + + // 200 for well-formed GET request. Page Cache hit because of HEAD request. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + if (!static::$auth) { + $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache')); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + // Comparing the exact serialization is pointless, because the order of + // fields does not matter (at least not yet). That's why we only compare the + // normalized entity with the decoded response: it's comparing PHP arrays + // instead of strings. + $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string)$response->getBody(), static::$format)); + // Not only assert the normalization, also assert deserialization of the + // response results in the expected object. + $unserialized = $this->serializer->deserialize((string)$response->getBody(), get_class($this->entity), static::$format); + $this->assertSame($unserialized->uuid(), $this->entity->uuid()); + $get_headers = $response->getHeaders(); + + // Verify that the GET and HEAD responses are the same, that the only + // difference is that there's no body. + $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache']; + foreach ($ignored_headers as $ignored_header) { + unset($head_headers[$ignored_header]); + unset($get_headers[$ignored_header]); + } + $this->assertSame($get_headers, $head_headers); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + + + // DX: 403 when unauthorized. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityType]); + + + // 200 for well-formed request. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + $url->setOption('query', ['_format' => 'non_existing_format']); + + + // DX: 406 when requesting unsupported format. + $response = $this->request('GET', $url, $request_options); + $this->assert406Response($response); + // @todo this works fine locally, but on testbot it comes back with 'text/plain; charset=UTF-8'. WTF. +// $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + + + $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType; + + + // DX: 406 when requesting unsupported format but specifying Accept header. + $response = $this->request('GET', $url, $request_options); + $this->assert406Response($response); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + + + $url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format); + $url->setRouteParameter(static::$entityType, 987654321); + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when GETting non-existing entity. + $response = $this->request('GET', $url, $request_options); + $path = str_replace('987654321', '{' . static::$entityType . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); + $message = 'The "' . static::$entityType . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityType . '.GET.' . static::$format . '")'; + $this->assertResourceErrorResponse(404, $message, $response); + } + + /** + * Gets an entity resource's GET/PATCH/DELETE URL. + * + * @return \Drupal\Core\Url + * The URL to GET/PATCH/DELETE. + */ + protected function getUrl() { + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityType . '/' . $this->entity->id()); + } + + public function testPost() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); + return; + } + + $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->getNormalizedEntityToCreate(), static::$format); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedEntityToCreate(), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->getInvalidNormalizedEntityToCreate(), static::$format); + $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedEntityToCreate() + ['uuid' => $this->randomMachineName(129)], static::$format); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getPostUrl(); + $request_options = []; + + + // DX: 404 when resource not provisioned, but HTML if canonical route. + $response = $this->request('POST', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when resource not provisioned. + $response = $this->request('POST', $url, $request_options); + // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'. + $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getPostUrl()->setAbsolute()->toString()) . '"', $response); + + + $this->provisionEntityResource(); + // Simulate the developer again forgetting the ?_format query string. + $url->setOption('query', []); + + + // DX: 415 when no Content-Type request header, but HTML if canonical route. + $response = $this->request('POST', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(415, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertTrue(FALSE !== strpos($response->getBody()->getContents(), htmlspecialchars('No "Content-Type" request header specified'))); + } + else { + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 415 when no Content-Type request header. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // DX: 400 when no request body. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(400, 'No entity content received.', $response); + + + $request_options[RequestOptions::BODY] = $unparseable_request_body; + + + // DX: 400 when unparseable request body. + $response = $this->request('POST', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands. +// $this->assertResourceErrorResponse(400, 'Syntax error', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody()); + + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + + + if (static::$auth) { + // DX: forgetting authentication: authentication provider-specific error + // response. + $response = $this->request('POST', $url, $request_options); + $this->verifyResponseWhenMissingAuthentication($response); + } + + + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions()); + + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('POST'); + + + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelField; + $label_field_capitalized = ucfirst($label_field); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. +// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: Title: this field cannot hold more than 1 values.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; + + + // DX: 422 when invalid entity: UUID field too long. + $response = $this->request('POST', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. +// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $this->assertSame([str_replace($this->entity->id(), static::$firstCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location')); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2; + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityType]); + + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $this->assertSame([str_replace($this->entity->id(), static::$secondCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location')); + } + + /** + * Gets an entity resource's POST URL. + * + * @return \Drupal\Core\Url + * The URL to POST to. + */ + protected function getPostUrl() { + $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create'); + return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityType); + } + + /** + * Decorates ::getNormalizedEntityToCreate(). + */ + protected function getInvalidNormalizedEntityToCreate() { + $normalization = $this->getNormalizedEntityToCreate(); + + // Add a second label to this entity to make it invalid. + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelField; + $normalization[$label_field][1]['value'] = 'Second Title'; + + return $normalization; + } + + /** + * Asserts a 406 response… or in some cases a 403 response, because weirdness. + * + * Asserting a 406 response should be easy, but it's not, due to bugs. + * + * Drupal returns a 403 response instead of a 406 response when: + * - there is a canonical route, i.e. one that serves HTML + * - unless the user is logged in with any non-global authentication provider, + * because then they tried to access a route that requires the user to be + * authenticated, but they used an authentication provider that is only + * accepted for specific routes, and HTML routes never have such specific + * authentication providers specified. (By default, only 'cookie' is a + * global authentication provider.) + * + * @todo Remove this in https://www.drupal.org/node/2805279. + * + * @param \Psr\Http\Message\ResponseInterface $response + * The response to assert. + */ + protected function assert406Response(ResponseInterface $response) { + if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) { + $this->assertSame(403, $response->getStatusCode()); + } + else { + // This is the desired response. + $this->assertSame(406, $response->getStatusCode()); + } + } + + /** + * Simulate common developer mistake when performing an unsafe operation: + * - forget to specify the X-CSRF-Token request header + * - specify in invalid X-CSRF-Token request header value + * + * In either case, the REST module must provide meaningful feedback for DX. + */ + protected function performUnsafeOperation($method) { + // Try without CSRF token + // …request + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('X-CSRF-Token request header is missing'); + // Try with invalid CSRF token + // …request + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('X-CSRF-Token request header is invalid'); + // Try with valid CSRF token + // …request + } + +} diff --git a/core/modules/rest/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; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create(array( + 'name' => 'Llama', + 'type' => 'entity_test', + )); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $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 getNormalizedEntityToCreate() { + 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; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "Camelids" node type. + NodeType::create([ + 'name' => 'Camelids', + 'type' => 'camelids', + ])->save(); + + // Create a "Llama" node. + $node = Node::create(['type' => 'camelids']); + $node->setTitle('Llama') + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789) + ->setRevisionCreationTime(123456789) + ->save(); + + return $node; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $author = User::load(0); + return [ + 'nid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'vid' => [ + ['value' => 1], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'type' => [ + [ + 'target_id' => 'camelids', + 'target_type' => 'node_type', + 'target_uuid' => NodeType::load('camelids')->uuid(), + ], + ], + 'title' => [ + [ + 'value' => 'Llama', + ], + ], + 'status' => [ + [ + 'value' => 1, + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'promote' => [ + [ + 'value' => 1, + ], + ], + 'sticky' => [ + [ + 'value' => '0', + ], + ], + 'revision_timestamp' => [ + [ + 'value' => '123456789', + ], + ], + 'revision_translation_affected' => [ + [ + 'value' => TRUE, + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + 'uid' => [ + [ + 'target_id' => '0', + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/0', + ], + ], + 'revision_uid' => [ + [ + 'target_id' => '0', + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/0', + ], + ], + 'revision_log' => [ + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + 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/RoleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php new file mode 100644 index 0000000..791d40c --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php @@ -0,0 +1,59 @@ +grantPermissionsToTestedRole(['administer permissions']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $role = Role::create([ + 'id' => 'llama', + 'name' => $this->randomString(), + ]); + $role->save(); + + return $role; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + return [ + 'uuid' => $this->entity->uuid(), + 'weight' => 2, + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'id' => 'llama', + 'label' => NULL, + 'is_admin' => NULL, + 'permissions' => [], + ]; + } + +} 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': + // @todo Create issue similar to https://www.drupal.org/node/2808217. + $this->grantPermissionsToTestedRole(['administer taxonomy']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // 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() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + 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 getNormalizedEntityToCreate() { + 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': + $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() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + 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 getNormalizedEntityToCreate() { + return [ + 'name' => [ + [ + 'value' => 'Dramallama ' . $this->randomMachineName(), + ], + ], + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php new file mode 100644 index 0000000..4e29106 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php @@ -0,0 +1,37 @@ +markTestSkipped(); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php new file mode 100644 index 0000000..8549963 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +grantPermissionsToTestedRole(['administer taxonomy']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $vocabulary = Vocabulary::create([ + 'name' => 'Llama', + 'vid' => 'llama', + ]); + $vocabulary->save(); + + return $vocabulary; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + return [ + 'uuid' => $this->entity->uuid(), + 'vid' => 'llama', + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'name' => 'Llama', + 'description' => NULL, + 'hierarchy' => 0, + 'weight' => 0, + ]; + } + +} 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..2d76a71 --- /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..dac5305 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -0,0 +1,315 @@ +getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert('[] === $user_role->getPermissions()', 'The anonymous user role has no permissions at all.'); + + if (static::$auth !== FALSE) { + // Ensure the authenticated user role has no permissions at all. + $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); + foreach ($user_role->getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert('[] === $user_role->getPermissions()', 'The authenticated user role has no permissions at all.'); + + // Create an account. + $this->account = $this->createUser(); + } + else { + // Otherwise, also create an account, so that any test involving User + // entities will have the same user IDs regardless of authentication. + $this->createUser(); + } + + $this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config'); + + // Ensure there's a clean slate: delete all REST resource config entities. + $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple()); + } + + /** + * Provisions a REST resource. + * + * @param string $resource_type + * The resource type (REST resource plugin ID). + * @param string[] $formats + * The allowed formats for this resource. + * @param string[] $authentication + * The allowed authentication providers for this resource. + */ + protected function provisionResource($resource_type, $formats = [], $authentication = []) { + $this->resourceConfigStorage->create([ + 'id' => $resource_type, + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'], + 'formats' => $formats, + 'authentication' => $authentication, + ] + ])->save(); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + } + + /** + * Sets up the necessary authorization. + * + * In case of a test verifying publicly accessible REST resources: grant + * permissions to the anonymous user role. + * + * In case of a test verifying behavior when using a particular authentication + * provider: create a user with a particular set of permissions. + * + * Because of the $method parameter, it's possible to first set up + * authentication for only GET, then add POST, et cetera. This then also + * allows for verifying a 403 in case of missing authorization. + * + * @param string $method + * The HTTP method for which to set up authentication. + * + * @return void + * + * @see ::grantPermissionsToAnonymousRole() + * @see ::grantPermissionsToAuthenticatedRole() + */ + abstract protected function setUpAuthorization($method); + + /** + * Verifies the error response in case of missing authentication. + * + * @return void + */ + abstract protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response); + + /** + * Returns Guzzle request options for authentication. + * + * @return array + * Guzzle request options to use for authentication. + * + * @see \GuzzleHttp\ClientInterface::request() + */ + protected function getAuthenticationRequestOptions() { + 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 any error response is returned via + * an exception, which would make the tests unnecessarily complex to read. + * + * @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) { + try { + $response = $this->httpClient->request($method, $url->toString(), $request_options); + } + catch (ClientException $e) { + $response = $e->getResponse(); + } + catch (ServerException $e) { + $response = $e->getResponse(); + } + return $response; + } + + /** + * Asserts that a resource response has the given status code and body. + * + * (Also asserts that the expected error MIME type is present, but this is + * defined globally for the test via static::$expectedErrorMimeType, because + * all error responses should use the same MIME type.) + * + * @param int $expected_status_code + * The expected response status. + * @param string|false $expected_body + * The expected response body. FALSE in case this should not be asserted. + * @param \Psr\Http\Message\ResponseInterface $response + * The response to assert. + */ + protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response) { + $this->assertSame($expected_status_code, $response->getStatusCode()); + if ($expected_status_code < 400) { + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + } + else { + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + } + if ($expected_body !== FALSE) { + $this->assertSame($expected_body, (string) $response->getBody()); + } + } + + /** + * Asserts that a resource error response has the given message. + * + * (Also asserts that the expected error MIME type is present, but this is + * defined globally for the test via static::$expectedErrorMimeType, because + * all error responses should use the same MIME type.) + * + * @param int $expected_status_code + * The expected response status. + * @param string $expected_message + * The expected error message. + * @param \Psr\Http\Message\ResponseInterface $response + * The error response to assert. + */ + protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) { + // @todo Either add this to \Drupal\serialization\Encoder\JsonEncoder, or + // figure out how to let tests specify encoder options, and figure out + // whether they should apply to just error responses or to everything. + $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT]; + $expected_body = $this->serializer->encode(['message' => $expected_message], static::$format, $encode_options); + $this->assertResourceResponse($expected_status_code, $expected_body, $response); + } + +}