.../EntityResource/Block/BlockHalJsonAnonTest.php | 42 +++ .../Block/BlockHalJsonBasicAuthTest.php | 53 ++++ .../EntityTest/EntityTestHalJsonAnonTest.php | 90 ++++++ .../EntityTest/EntityTestHalJsonBasicAuthTest.php | 37 +++ .../EntityResource/HalEntityNormalizationTrait.php | 56 ++++ .../EntityResource/Node/NodeHalJsonAnonTest.php | 111 ++++++++ .../Node/NodeHalJsonBasicAuthTest.php | 37 +++ .../EntityResource/Role/RoleHalJsonAnonTest.php | 42 +++ .../Role/RoleHalJsonBasicAuthTest.php | 68 +++++ .../EntityResource/Term/TermHalJsonAnonTest.php | 64 +++++ .../Term/TermHalJsonBasicAuthTest.php | 54 ++++ .../HalJsonBasicAuthWorkaroundFor2805281Trait.php | 24 ++ .../tests/src/Functional/AnonResourceTestTrait.php | 28 ++ .../src/Functional/BasicAuthResourceTestTrait.php | 30 ++ .../EntityResource/Block/BlockJsonAnonTest.php | 36 +++ .../Block/BlockJsonBasicAuthTest.php | 52 ++++ .../EntityResource/Block/BlockResourceTestBase.php | 99 +++++++ .../EntityResource/EntityResourceTestBase.php | 309 +++++++++++++++++++++ .../EntityTest/EntityTestJsonAnonTest.php | 36 +++ .../EntityTest/EntityTestJsonBasicAuthTest.php | 53 ++++ .../EntityTest/EntityTestResourceTestBase.php | 83 ++++++ .../EntityResource/Node/NodeJsonAnonTest.php | 36 +++ .../EntityResource/Node/NodeJsonBasicAuthTest.php | 53 ++++ .../EntityResource/Node/NodeResourceTestBase.php | 136 +++++++++ .../EntityResource/Role/RoleJsonAnonTest.php | 36 +++ .../EntityResource/Role/RoleJsonBasicAuthTest.php | 70 +++++ .../EntityResource/Role/RoleResourceTestBase.php | 49 ++++ .../EntityResource/Term/TermJsonAnonTest.php | 36 +++ .../EntityResource/Term/TermJsonBasicAuthTest.php | 52 ++++ .../EntityResource/Term/TermResourceTestBase.php | 88 ++++++ .../JsonBasicAuthWorkaroundFor2805281Trait.php | 25 ++ .../rest/tests/src/Functional/ResourceTestBase.php | 170 ++++++++++++ 32 files changed, 2155 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..f54dce2 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php @@ -0,0 +1,42 @@ +grantPermissionsToAnonymousRole(['administer blocks']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php new file mode 100644 index 0000000..6f778b9 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php @@ -0,0 +1,53 @@ +account = $this->createUser(['administer blocks']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php new file mode 100644 index 0000000..8b93b9b --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php @@ -0,0 +1,90 @@ +grantPermissionsToAnonymousRole(['view test entity']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->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', + ], + ], + ], + ]; + } + +} 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..bb9f420 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php @@ -0,0 +1,37 @@ +account = $this->createUser(['view test entity']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php new file mode 100644 index 0000000..49ac07d --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php @@ -0,0 +1,56 @@ +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..94ce785 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -0,0 +1,111 @@ +grantPermissionsToAnonymousRole(['access content']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->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()] + ], + ], + ], + ], + ]; + } + +} 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..b48bb66 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php @@ -0,0 +1,37 @@ +account = $this->createUser(['access content']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php new file mode 100644 index 0000000..1ecf214 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php @@ -0,0 +1,42 @@ +grantPermissionsToAnonymousRole(['administer permissions']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php new file mode 100644 index 0000000..269e8f1 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php @@ -0,0 +1,68 @@ +account = $this->createUser(['administer permissions']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + return [ + // Edge case: the weight is not 2 like in the default normalization, but 3 + // because setUpAuthentication() creates a new user role (which gets + // weight 2), and then the entity that is created for testing ends up with + // weight 3. + // This only affects user_role entity tests for authenticated users. + 'weight' => 3, + ] + $default_normalization; + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php new file mode 100644 index 0000000..c334386 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php @@ -0,0 +1,64 @@ +grantPermissionsToAnonymousRole(['access content']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->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', + ], + ], + ]; + } + +} 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..9ba8404 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php @@ -0,0 +1,54 @@ +account = $this->createUser(['access content']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php new file mode 100644 index 0000000..c4c6e39 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php @@ -0,0 +1,24 @@ +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/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php new file mode 100644 index 0000000..b8b04b1 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php @@ -0,0 +1,28 @@ +grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions); + } + + /** + * {@inheritdoc} + */ + protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) { + throw new \LogicException('When testing for anonymous users, authentication cannot be missing.'); + } + +} diff --git a/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php new file mode 100644 index 0000000..bb1c997 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php @@ -0,0 +1,30 @@ + [ + 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->assertErrorResponse(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..ebfe7ac --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php @@ -0,0 +1,36 @@ +grantPermissionsToAnonymousRole(['administer blocks']); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php new file mode 100644 index 0000000..c5b803e --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php @@ -0,0 +1,52 @@ +account = $this->createUser(['administer blocks']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php new file mode 100644 index 0000000..9430a35 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php @@ -0,0 +1,99 @@ + '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. + if ($this->account) { + $block->setVisibilityConfig('user_role', [ + 'id' => 'user_role', + 'roles' => [ + 'authenticated' => 'authenticated', + ], + 'negate' => FALSE, + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + ]); + } + $block->save(); + + return $block; + } + + 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' => [], + ]; + if ($this->account) { + $normalization['dependencies']['module'][] = 'user'; + $normalization['visibility']['user_role'] = [ + 'id' => 'user_role', + 'roles' => [ + 'authenticated' => 'authenticated', + ], + 'negate' => FALSE, + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + ]; + } + + 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..9ada181 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -0,0 +1,309 @@ +provisionResource('entity.' . static::$entityType, [static::$format], $auth); + } + + 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(); + } + + /** + * 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() { + // @todo test what happens before provisioning. Edge case: node. + + // Provision REST resource. + $this->provisionEntityResource(); + + /** @var \Psr\Http\Message\ResponseInterface $response */ + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + $url = $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityType . '/' . $this->entity->id()); + + // Requesting without the appropriate ?_format query argument. + try { + $response = $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions()); + } catch (ClientException $e) { + $response = $e->getResponse(); + } + // When the anonymous user accesses a canonical (HTML) route, we won't get a + // 406, but a 200, and that is appropriate when no format is specified. + if ($has_canonical_url && !$this->account) { + $this->assertSame(200, $response->getStatusCode()); + } + else { + $this->assert406Response($response); + } + + $url->setOption('query', ['_format' => static::$format]); + + // Verify that the entity exists: use a HEAD request. + $response = $this->httpClient->head($url->toString(), $this->getAuthenticationRequestOptions()); + $this->assertSame(200, $response->getStatusCode()); + if (!$this->account) { + $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache')); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + $this->assertSame('', (string)$response->getBody()); + $head_headers = $response->getHeaders(); + + // Now do a GET request. And because of the preceding HEAD request, this + // should be a Page Cache hit. + $response = $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + if (!$this->account) { + $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); + + if ($this->account) { + // Try to read the entity without the necessary authentication. + try { + $this->httpClient->get($url->toString()); + $this->fail('No error response for missing authentication!'); + } + catch (ClientException $e) { + $this->verifyResponseWhenMissingAuthentication($e->getResponse()); + } + } + + // Try to read the entity in an unsupported format. + try { + $url->setOption('query', ['_format' => 'non_existing_format']); + $this->httpClient->get($url->toString(), $this->getAuthenticationRequestOptions()); + $this->fail('No 406 response received.'); + } + catch (ClientException $e) { + $response = $e->getResponse(); + $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')); + } + + // Repeat the same request, but this time with the format's MIME type in the + // Accept header. This time we should get a response with the expected error + // MIME type. + try { + $options = $this->getAuthenticationRequestOptions(); + $options['headers']['Accept'] = static::$mimeType; + $url->setOption('query', ['_format' => 'non_existing_format']); + $this->httpClient->get($url->toString(), $options); + $this->fail('No 406 response received.'); + } + catch (ClientException $e) { + $response = $e->getResponse(); + $this->assert406Response($response); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + } + + // Try to read an entity that does not exist. + $non_existing_entity_url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format); + $non_existing_entity_url->setRouteParameter(static::$entityType, 987654321); + $non_existing_entity_url->setOption('query', ['_format' => static::$format]); + try { + $this->httpClient->get($non_existing_entity_url->toString()); + $this->fail('No 404 response received.'); + } + catch (ClientException $e) { + $path = str_replace('987654321', '{' . static::$entityType . '}', $non_existing_entity_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->assertErrorResponse(404, $message, $e->getResponse()); + } + } + + /** + * Asserts that an error response has the given status code and 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 assertErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) { + $this->assertSame($expected_status_code, $response->getStatusCode()); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + // @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]; + $this->assertSame($this->serializer->encode(['message' => $expected_message], static::$format, $encode_options), (string)$response->getBody()); + } + + // @todo try the following request bodies: + // 1. empty (only makes sense for DELETE and GET), assert 400 + // 2. not parseable, assert 400 + // 3. parseable but invalid (with various possible errors, possibly entity type specific ones?), assert 422 + // 4. parseable and valid, assert 201 + public function atestPost() { + // Attempt to use REST resource while not provisioned. + $this->performUnsafeOperation('POST'); + $this->assertSame(404, $this->getSession()->getStatusCode()); + $this->assertFalse(EntityType::loadMultiple(), 'No entity has been created in the database.'); + + // Provision REST resource. + $this->provisionEntityResource(); + + // Try as user without 'create' access for this entity type. + $this->performUnsafeOperation('POST'); + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('not allowed …'); + + // Try as user with sufficient permissions. + $this->performUnsafeOperation('POST'); + $this->assertSame(201, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('…'); + } + + /** + * 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..97b61bb --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php @@ -0,0 +1,36 @@ +grantPermissionsToAnonymousRole(['view test entity']); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php new file mode 100644 index 0000000..3c83852 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php @@ -0,0 +1,53 @@ +account = $this->createUser(['view test entity']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php new file mode 100644 index 0000000..69a2a7b --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php @@ -0,0 +1,83 @@ + 'Llama', + 'type' => 'entity_test', + )); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + 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; + } + +} 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..596dd1c --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php @@ -0,0 +1,36 @@ +grantPermissionsToAnonymousRole(['access content']); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php new file mode 100644 index 0000000..d293972 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php @@ -0,0 +1,53 @@ +account = $this->createUser(['access content']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php new file mode 100644 index 0000000..1b4538b --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -0,0 +1,136 @@ + '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; + } + + 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' => [ + ], + ]; + } + + // methods to provide one-off expectations/data/… +} 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..103a208 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php @@ -0,0 +1,36 @@ +grantPermissionsToAnonymousRole(['administer permissions']); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php new file mode 100644 index 0000000..05a0150 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php @@ -0,0 +1,70 @@ +account = $this->createUser(['administer permissions']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + return [ + // Edge case: the weight is not 2 like in the default normalization, but 3 + // because setUpAuthentication() creates a new user role (which gets + // weight 2), and then the entity that is created for testing ends up with + // weight 3. + // This only affects user_role entity tests for authenticated users. + 'weight' => 3, + ] + $default_normalization; + } + + /** + * {@inheritdoc} + */ + protected function verifyResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->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..4255e2d --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php @@ -0,0 +1,49 @@ + 'llama', + 'name' => $this->randomString(), + ]); + $role->save(); + + return $role; + } + + 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..60fa865 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php @@ -0,0 +1,36 @@ +grantPermissionsToAnonymousRole(['access content']); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php new file mode 100644 index 0000000..84c5cf2 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php @@ -0,0 +1,52 @@ +account = $this->createUser(['access content']); + } + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::verifyResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php new file mode 100644 index 0000000..f1c3bbc --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php @@ -0,0 +1,88 @@ + 'Camelids', + 'vid' => 'camelids', + ]); + $vocabulary->save(); + + // Create a "Llama" taxonomy term. + $term = Term::create(['vid' => $vocabulary->id()]) + ->setName('Llama') + ->setChangedTime(123456789); + $term->save(); + + return $term; + } + + 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, + ], + ], + ]; + } + +} 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..22132c4 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -0,0 +1,170 @@ +getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert('[] === $user_role->getPermissions()', 'The anonymous user has no permissions at all.'); + + // Then allow concrete tests to set up authentication: grant permissions to + // the anonymous user, or create a user with the necessary permissions. + $this->setUpAuthentication(); + + $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(); + + $this->container->get('router.builder')->rebuild(); + } + + /** + * Sets up the necessary authentication. + * + * 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. + * + * @return void + */ + abstract protected function setUpAuthentication(); + + /** + * 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 []; + } + +}