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 0000000000000000000000000000000000000000..d0758f347c934fbbcb96688e407840df67d50ff0 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonAnonTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Block; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase; + +/** + * @group hal + */ +class BlockHalJsonAnonTest extends BlockResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..81ebf551bb6d3dd658b535676daa6a6cd95ebeab --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Block; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase; + +/** + * @group hal + */ +class BlockHalJsonBasicAuthTest extends BlockResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal', 'basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1403ebef7889faf44610d3f4c2e8b7a59930fe28 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Block/BlockHalJsonCookieTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Block; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Block\BlockResourceTestBase; + +/** + * @group hal + */ +class BlockHalJsonCookieTest extends BlockResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3edd9b16098a253822a221fddbed5cdfe405dca9 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonAnonTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Comment; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group hal + */ +class CommentHalJsonAnonTest extends CommentHalJsonTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + * + * Anononymous users cannot edit their own comments. + * + * @see \Drupal\comment\CommentAccessControlHandler::checkAccess + * + * Therefore we grant them the 'administer comments' permission for the + * purpose of this test. Then they are able to edit their own comments, but + * some fields are still not editable, even with that permission. + * + * @see ::setUpAuthorization + */ + protected static $patchProtectedFieldNames = [ + 'changed', + 'thread', + 'entity_type', + 'field_name', + 'entity_id', + ]; + +} 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 0000000000000000000000000000000000000000..8b6e4a3f778fbc54f78e21dc5474f137b7fee026 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Comment; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; + +/** + * @group hal + */ +class CommentHalJsonBasicAuthTest extends CommentHalJsonTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..292dc94b64fb05125eeb28b2febda4bde708bd46 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonCookieTest.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Comment; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group hal + */ +class CommentHalJsonCookieTest extends CommentHalJsonTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..f4b03af46961e2b42e9b4116630545a4543c1f15 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Comment/CommentHalJsonTestBase.php @@ -0,0 +1,144 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Comment; + +use Drupal\Core\Cache\Cache; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait; +use Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase; +use Drupal\user\Entity\User; + +abstract class CommentHalJsonTestBase extends CommentResourceTestBase { + + use HalEntityNormalizationTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + * + * The HAL+JSON format causes different PATCH-protected fields. For some + * reason, the 'pid' and 'homepage' fields are NOT PATCH-protected, even + * though they are for non-HAL+JSON serializations. + * + * @todo fix in https://www.drupal.org/node/2824271 + */ + protected static $patchProtectedFieldNames = [ + 'created', + 'changed', + 'status', + 'thread', + 'entity_type', + 'field_name', + 'entity_id', + 'uid', + ]; + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->applyHalFieldNormalization($default_normalization); + + // Because \Drupal\comment\Entity\Comment::getOwner() generates an in-memory + // User entity without a UUID, we cannot use it. + $author = User::load($this->entity->getOwnerId()); + $commented_entity = EntityTest::load(1); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/comment/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/comment/comment', + ], + $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [ + [ + 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json', + ], + ], + $this->baseUrl . '/rest/relation/comment/comment/uid' => [ + [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + 'lang' => 'en', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/comment/comment/entity_id' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/entity_test/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/bar', + ], + ], + 'uuid' => [ + ['value' => $commented_entity->uuid()] + ], + ], + ], + $this->baseUrl . '/rest/relation/comment/comment/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/comment/comment', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // The 'url.site' cache context is added for '_links' in the response. + return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..45cedb2a98e8ec5179398a9327e8a7a8ce4d8173 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonAnonTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase; + +/** + * @group hal + */ +class ConfigTestHalJsonAnonTest extends ConfigTestResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..489a3a552023988642ee42d5283e2a97d120d8c9 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase; + +/** + * @group hal + */ +class ConfigTestHalJsonBasicAuthTest extends ConfigTestResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal', 'basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..846aa9f05dc71f8591915b69267d1b5cdaeb1bab --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/ConfigTest/ConfigTestHalJsonCookieTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\ConfigTest\ConfigTestResourceTestBase; + +/** + * @group hal + */ +class ConfigTestHalJsonCookieTest extends ConfigTestResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..fd02f12978c424fe33565dbc850a0414ee27a2b0 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php @@ -0,0 +1,105 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest; + +use Drupal\Core\Cache\Cache; +use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase; +use Drupal\user\Entity\User; + +/** + * @group hal + */ +class EntityTestHalJsonAnonTest extends EntityTestResourceTestBase { + + use HalEntityNormalizationTrait; + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@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', + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // The 'url.site' cache context is added for '_links' in the response. + return Cache::mergeTags(parent::getExpectedCacheContexts(), ['url.site']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5604e3b548c4d7a691f3d8df4a7c25e93ad37f98 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; + +/** + * @group hal + */ +class EntityTestHalJsonBasicAuthTest extends EntityTestHalJsonAnonTest { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..efca3178e35a0d9b31c07076c7d5ab279f81b90e --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonCookieTest.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\EntityTest; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group hal + */ +class EntityTestHalJsonCookieTest extends EntityTestHalJsonAnonTest { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..d3bd85a9a9dfe7f84a071d43b1c1cb0625c6f1a0 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/HalEntityNormalizationTrait.php @@ -0,0 +1,130 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource; + +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\EntityReferenceFieldItemListInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Url; +use GuzzleHttp\RequestOptions; + +/** + * Trait for EntityResourceTestBase subclasses testing formats using HAL. + */ +trait HalEntityNormalizationTrait { + + /** + * Applies the HAL entity field normalization to an entity normalization. + * + * The HAL normalization: + * - adds a 'lang' attribute to every translatable field + * - omits reference fields, since references are stored in _links & _embedded + * - omits empty fields (fields without value) + * + * @param array $normalization + * An entity normalization. + * + * @return array + * The updated entity normalization. + */ + protected function applyHalFieldNormalization(array $normalization) { + if (!$this->entity instanceof FieldableEntityInterface) { + throw new \LogicException('This trait should only be used for fieldable entity types.'); + } + + // In the HAL normalization, all translatable fields get a 'lang' attribute. + $translatable_non_reference_fields = array_keys(array_filter($this->entity->getTranslatableFields(), function (FieldItemListInterface $field) { + return !$field instanceof EntityReferenceFieldItemListInterface; + })); + foreach ($translatable_non_reference_fields as $field_name) { + if (isset($normalization[$field_name])) { + $normalization[$field_name][0]['lang'] = 'en'; + } + } + + // In the HAL normalization, reference fields are omitted, except for the + // bundle field. + $bundle_key = $this->entity->getEntityType()->getKey('bundle'); + $reference_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) use ($bundle_key) { + return $field instanceof EntityReferenceFieldItemListInterface && $field->getName() !== $bundle_key; + })); + foreach ($reference_fields as $field_name) { + unset($normalization[$field_name]); + } + + // In the HAL normalization, the bundle field omits the 'target_type' and + // 'target_uuid' properties, because it's encoded in the '_links' section. + if ($bundle_key) { + unset($normalization[$bundle_key][0]['target_type']); + unset($normalization[$bundle_key][0]['target_uuid']); + } + + // In the HAL normalization, empty fields are omitted. + $empty_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) { + return $field->isEmpty(); + })); + foreach ($empty_fields as $field_name) { + unset($normalization[$field_name]); + } + + return $normalization; + } + + /** + * {@inheritdoc} + */ + protected function removeFieldsFromNormalization(array $normalization, $field_names) { + $normalization = parent::removeFieldsFromNormalization($normalization, $field_names); + foreach ($field_names as $field_name) { + $relation_url = Url::fromUri('base:rest/relation/' . static::$entityTypeId . '/' . $this->entity->bundle() . '/' . $field_name) + ->setAbsolute(TRUE) + ->toString(); + $normalization['_links'] = array_diff_key($normalization['_links'], [$relation_url => TRUE]); + if (isset($normalization['_embedded'])) { + $normalization['_embedded'] = array_diff_key($normalization['_embedded'], [$relation_url => TRUE]); + } + } + + return array_diff_key($normalization, array_flip($field_names)); + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity + // types with bundles MUST send their bundle field to be denormalizable. + if ($this->entity->getEntityType()->hasKey('bundle')) { + $normalization = $this->getNormalizedPostEntity(); + + // @todo Uncomment this in https://www.drupal.org/node/2824827. + // @codingStandardsIgnoreStart +/* + $normalization['_links']['type'] = Url::fromUri('base:rest/type/' . static::$entityTypeId . '/bad_bundle_name'); + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + // DX: 400 when incorrect entity type bundle is specified. + $response = $this->request($method, $url, $request_options); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853. +// $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody()); +*/ + // @codingStandardsIgnoreEnd + + unset($normalization['_links']['type']); + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // DX: 400 when no entity type bundle is specified. + $response = $this->request($method, $url, $request_options); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853. + // $this->assertResourceErrorResponse(400, 'The type link relation must be specified.', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'The type link relation must be specified.'], static::$format), (string) $response->getBody()); + } + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c7f242812e4ade89897f76af84a0134626beaa3a --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -0,0 +1,137 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Node; + +use Drupal\Core\Cache\Cache; +use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase; +use Drupal\user\Entity\User; + +/** + * @group hal + */ +class NodeHalJsonAnonTest extends NodeResourceTestBase { + + use HalEntityNormalizationTrait; + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = [ + 'created', + 'changed', + 'promote', + 'sticky', + 'revision_timestamp', + 'revision_uid', + ]; + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->applyHalFieldNormalization($default_normalization); + + $author = User::load($this->entity->getOwnerId()); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/node/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // The 'url.site' cache context is added for '_links' in the response. + return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1d7bb62349720dd299415d9e3443949e4bbc9b13 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Node; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; + +/** + * @group hal + */ +class NodeHalJsonBasicAuthTest extends NodeHalJsonAnonTest { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..19ed3637c4a4fb273e2dfa0b1abfc3d2ae47cf9d --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTest.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Node; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group hal + */ +class NodeHalJsonCookieTest extends NodeHalJsonAnonTest { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..cad41042b05e58b13f9a9a20bb1c1f449a9c8128 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonAnonTest.php @@ -0,0 +1,35 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase; + +/** + * @group hal + */ +class RoleHalJsonAnonTest extends RoleResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..f540bb1e60d8ac673225561ee01a65a684eab5e0 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Role; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase; + +/** + * @group hal + */ +class RoleHalJsonBasicAuthTest extends RoleResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal', 'basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b52713c50bc1c2a1ae7773e4e3c455f75302acaf --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Role/RoleHalJsonCookieTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Role\RoleResourceTestBase; + +/** + * @group hal + */ +class RoleHalJsonCookieTest extends RoleResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..9c794ae4b73d936a884bfc26c063eb7c56a1a1a8 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php @@ -0,0 +1,79 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Term; + +use Drupal\Core\Cache\Cache; +use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase; + +/** + * @group hal + */ +class TermHalJsonAnonTest extends TermResourceTestBase { + + use HalEntityNormalizationTrait; + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@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', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // The 'url.site' cache context is added for '_links' in the response. + return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..8c7b04b651a9f345ff1a45218c8c8e5dbcdf63c8 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Term; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; + +/** + * @group hal + */ +class TermHalJsonBasicAuthTest extends TermHalJsonAnonTest { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..23ccd20526f3deffae6424461466993da7ff0454 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonCookieTest.php @@ -0,0 +1,18 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Term; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group hal + */ +class TermHalJsonCookieTest extends TermHalJsonAnonTest { + + use CookieResourceTestTrait; + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d839a722b1c7c34fe13cabb41e17b1ba84064fce --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonAnonTest.php @@ -0,0 +1,79 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\User; + +use Drupal\Core\Cache\Cache; +use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\User\UserResourceTestBase; + +/** + * @group hal + */ +class UserHalJsonAnonTest extends UserResourceTestBase { + + use HalEntityNormalizationTrait; + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $default_normalization = parent::getExpectedNormalizedEntity(); + + $normalization = $this->applyHalFieldNormalization($default_normalization); + + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/3?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // The 'url.site' cache context is added for '_links' in the response. + return Cache::mergeContexts(parent::getExpectedCacheContexts(), ['url.site']); + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..dbf17cb54ce474bce2acb9b4c8f7a288eb277ed5 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonBasicAuthTest.php @@ -0,0 +1,30 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\User; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; + +/** + * @group hal + */ +class UserHalJsonBasicAuthTest extends UserHalJsonAnonTest { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b4a121b53fe607b43dc10798dfcbb699d90d902f --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/User/UserHalJsonCookieTest.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\User; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group hal + */ +class UserHalJsonCookieTest extends UserHalJsonAnonTest { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..037ecc75174bfd4a7ce361f47d45cd1caec21145 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonAnonTest.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase; + +/** + * @group hal + */ +class VocabularyHalJsonAnonTest extends VocabularyResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * @todo Remove this override in https://www.drupal.org/node/2805281. + */ + public function testGet() { + $this->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 0000000000000000000000000000000000000000..4f7896e9bb352d6196df085539f7298c97954b85 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonBasicAuthTest.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\hal\Functional\HalJsonBasicAuthWorkaroundFor2805281Trait; +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase; + +/** + * @group hal + */ +class VocabularyHalJsonBasicAuthTest extends VocabularyResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal', 'basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use HalJsonBasicAuthWorkaroundFor2805281Trait { + HalJsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b751cdd2200183464d023fbd566f7712ef20e26d --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Vocabulary/VocabularyHalJsonCookieTest.php @@ -0,0 +1,40 @@ +<?php + +namespace Drupal\Tests\hal\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\Vocabulary\VocabularyResourceTestBase; + +/** + * @group hal + */ +class VocabularyHalJsonCookieTest extends VocabularyResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['hal']; + + /** + * {@inheritdoc} + */ + protected static $format = 'hal_json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/hal+json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..ef77d96ceaba5f2332485d7b4ae9faaf3a4c4346 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/HalJsonBasicAuthWorkaroundFor2805281Trait.php @@ -0,0 +1,26 @@ +<?php + +namespace Drupal\Tests\hal\Functional; + +use Psr\Http\Message\ResponseInterface; + +trait HalJsonBasicAuthWorkaroundFor2805281Trait { + + /** + * {@inheritdoc} + * + * Note how the response claims it contains a application/hal+json body, but + * in reality it contains a text/plain body! Also, the correct error MIME type + * is application/json. + * + * @todo Fix in https://www.drupal.org/node/2805281: remove this trait. + */ + protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->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 0000000000000000000000000000000000000000..cf9efee46b35a917b2b0d8e2ed744cd7d34567ce --- /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 0000000000000000000000000000000000000000..fcd9979a11ab5b4479b526c41507e702bf8143eb --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.module @@ -0,0 +1,30 @@ +<?php + +/** + * @file + * Contains hook implementations for testing REST module. + */ + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Session\AccountInterface; + +/** + * Implements hook_entity_type_alter(). + */ +function config_test_rest_entity_type_alter(array &$entity_types) { + // Undo part of what config_test_entity_type_alter() did: remove this + // config_test_no_status entity type, because it uses the same entity class as + // the config_test entity type, which makes REST deserialization impossible. + unset($entity_types['config_test_no_status']); +} + +/** + * Implements hook_ENTITY_TYPE_access(). + */ +function config_test_rest_config_test_access(EntityInterface $entity, $operation, AccountInterface $account) { + // Add permission, so that EntityResourceTestBase's scenarios can test access + // being denied. By default, all access is always allowed for the config_test + // config entity. + return AccessResult::forbiddenIf(!$account->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 0000000000000000000000000000000000000000..b8fd229fd3b1b7d27fcbfd76945a9d5b6c742c86 --- /dev/null +++ b/core/modules/rest/tests/modules/config_test_rest/config_test_rest.permissions.yml @@ -0,0 +1,2 @@ +view config_test: + title: 'View ConfigTest entities' diff --git a/core/modules/rest/tests/modules/rest_test/rest_test.module b/core/modules/rest/tests/modules/rest_test/rest_test.module index 272603d157fdb9af016b5778c7269b1410b1230b..01511ea1e0ba74ff458459104aae9f07fd9ee372 100644 --- a/core/modules/rest/tests/modules/rest_test/rest_test.module +++ b/core/modules/rest/tests/modules/rest_test/rest_test.module @@ -5,6 +5,11 @@ * Contains hook implementations for testing REST module. */ +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Access\AccessResult; + /** * Implements hook_rest_type_uri_alter(). */ @@ -22,3 +27,24 @@ function rest_test_rest_relation_uri_alter(&$uri, $context = array()) { $uri = 'rest_test_relation'; } } + +/** + * Implements hook_entity_field_access(). + * + * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::setUp() + * @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost() + */ +function rest_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) { + if ($field_definition->getName() === 'field_rest_test') { + switch ($operation) { + case 'view': + // Never ever allow this field to be viewed: this lets EntityResourceTestBase::testGet() test in a "vanilla" way. + return AccessResult::forbidden(); + case 'edit': + return AccessResult::forbidden(); + } + } + + // No opinion. + return AccessResult::neutral(); +} diff --git a/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..b05ddf2a9cb3eaac6083c946752af4f10ad5f2b6 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/AnonResourceTestTrait.php @@ -0,0 +1,36 @@ +<?php + +namespace Drupal\Tests\rest\Functional; + +use Drupal\Core\Url; +use Psr\Http\Message\ResponseInterface; + +/** + * Trait for ResourceTestBase subclasses testing $auth=NULL, i.e. authless/anon. + * + * Characteristics: + * - When no authentication provider is being used, there also cannot be any + * particular error response for missing authentication, since by definition + * there is not any authentication. + * - For the same reason, there are no authentication edge cases to test. + * - Because no authentication is required, this is vulnerable to CSRF attacks + * by design. Hence a REST resource should probably only allow for anonymous + * for safe (GET/HEAD) HTTP methods, and only with extreme care should unsafe + * (POST/PATCH/DELETE) HTTP methods be allowed for a REST resource that allows + * anonymous access. + */ +trait AnonResourceTestTrait { + + /** + * {@inheritdoc} + */ + protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) { + throw new \LogicException('When testing for anonymous users, authentication cannot be missing.'); + } + + /** + * {@inheritdoc} + */ + protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {} + +} 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 0000000000000000000000000000000000000000..6f8c6217271c8b5ee2f70d722885b2dd014749f2 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/BasicAuthResourceTestTrait.php @@ -0,0 +1,43 @@ +<?php + +namespace Drupal\Tests\rest\Functional; + +use Drupal\Core\Url; +use Psr\Http\Message\ResponseInterface; + +/** + * Trait for ResourceTestBase subclasses testing $auth=basic_auth. + * + * Characteristics: + * - Every request must send an Authorization header. + * - When accessing a URI that requires authentication without being + * authenticated, a 401 response must be sent. + * - Because every request must send an authorization, there is no danger of + * CSRF attacks. + */ +trait BasicAuthResourceTestTrait { + + /** + * {@inheritdoc} + */ + protected function getAuthenticationRequestOptions($method) { + return [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response); + } + + /** + * {@inheritdoc} + */ + protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {} + +} diff --git a/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..c231ffb6939c2ab37625793b17783a2e722d86f8 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/CookieResourceTestTrait.php @@ -0,0 +1,129 @@ +<?php + +namespace Drupal\Tests\rest\Functional; + +use Drupal\Core\Url; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; + +/** + * Trait for ResourceTestBase subclasses testing $auth=cookie. + * + * Characteristics: + * - After performing a valid "log in" request, the server responds with a 2xx + * status code and a 'Set-Cookie' response header. This cookie is what + * continues to identify the user in subsequent requests. + * - When accessing a URI that requires authentication without being + * authenticated, a standard 403 response must be sent. + * - Because of the reliance on cookies, and the fact that user agents send + * cookies with every request, this is vulnerable to CSRF attacks. To mitigate + * this, the response for the "log in" request contains a CSRF token that must + * be sent with every unsafe (POST/PATCH/DELETE) HTTP request. + */ +trait CookieResourceTestTrait { + + /** + * The session cookie. + * + * @see ::initAuthentication + * + * @var string + */ + protected $sessionCookie; + + /** + * The CSRF token. + * + * @see ::initAuthentication + * + * @var string + */ + protected $csrfToken; + + /** + * The logout token. + * + * @see ::initAuthentication + * + * @var string + */ + protected $logoutToken; + + /** + * {@inheritdoc} + */ + protected function initAuthentication() { + // @todo Remove hardcoded use of the 'json' format, and use static::$format + // + static::$mimeType instead in https://www.drupal.org/node/2820888. + $user_login_url = Url::fromRoute('user.login.http') + ->setRouteParameter('_format', 'json'); + + $request_body = [ + 'name' => $this->account->name->value, + 'pass' => $this->account->passRaw, + ]; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, 'json'); + $request_options[RequestOptions::HEADERS]['Accept'] = 'application/json'; + $response = $this->request('POST', $user_login_url, $request_options); + + // Parse and store the session cookie. + $this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0]; + + // Parse and store the CSRF token and logout token. + $data = $this->serializer->decode($response->getBody()->getContents(), static::$format); + $this->csrfToken = $data['csrf_token']; + $this->logoutToken = $data['logout_token']; + } + + /** + * {@inheritdoc} + */ + protected function getAuthenticationRequestOptions($method) { + $request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie; + // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html + if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) { + $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken; + } + return $request_options; + } + + /** + * {@inheritdoc} + */ + protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->assertResourceErrorResponse(403, '', $response); + } + + /** + * {@inheritdoc} + */ + protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) { + // X-CSRF-Token request header is unnecessary for safe and side effect-free + // HTTP methods. No need for additional assertions. + // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html + if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) { + return; + } + + + unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']); + + + // DX: 403 when missing X-CSRF-Token request header. + $response = $this->request($method, $url, $request_options); + $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response); + + + $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for'; + + + // DX: 403 when invalid X-CSRF-Token request header. + $response = $this->request($method, $url, $request_options); + $this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response); + + + $request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9c764bda71ab1d9209443264ff372dc70784a903 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Block; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class BlockJsonAnonTest extends BlockResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..f191e124abfcbc07c7f049651a99662e75609952 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Block; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class BlockJsonBasicAuthTest extends BlockResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f609448f6beacfd0dd316cfd1d198cba65b5ab94 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Block; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class BlockJsonCookieTest extends BlockResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..94c0b047505cf395513813ad9182a0cce3eeeb7c --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php @@ -0,0 +1,130 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Block; + +use Drupal\block\Entity\Block; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; + +abstract class BlockResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['block']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'block'; + + /** + * @var \Drupal\block\BlockInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->entity->setVisibilityConfig('user_role', [])->save(); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['administer blocks']); + break; + case 'PATCH': + $this->grantPermissionsToTestedRole(['administer blocks']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $block = Block::create([ + 'plugin' => 'llama_block', + 'region' => 'header', + 'id' => 'llama', + 'theme' => 'classy', + ]); + // All blocks can be viewed by the anonymous user by default. An interesting + // side effect of this is that any anonymous user is also able to read the + // corresponding block config entity via REST, even if an authentication + // provider is configured for the block config entity REST resource! In + // other words: Block entities do not distinguish between 'view' as in + // "render on a page" and 'view' as in "read the configuration". + // This prevents that. + // @todo Fix this in https://www.drupal.org/node/2820315. + $block->setVisibilityConfig('user_role', [ + 'id' => 'user_role', + 'roles' => ['non-existing-role' => 'non-existing-role'], + 'negate' => FALSE, + 'context_mapping' => [ + 'user' => '@user.current_user_context:current_user', + ], + ]); + $block->save(); + + return $block; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $normalization = [ + 'uuid' => $this->entity->uuid(), + 'id' => 'llama', + 'weight' => NULL, + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [ + 'theme' => [ + 'classy', + ], + ], + 'theme' => 'classy', + 'region' => 'header', + 'provider' => NULL, + 'plugin' => 'llama_block', + 'settings' => [ + 'id' => 'broken', + 'label' => '', + 'provider' => 'core', + 'label_display' => 'visible', + ], + 'visibility' => [], + ]; + + return $normalization; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // @see ::createEntity() + return []; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheTags() { + // Because the 'user.permissions' cache context is missing, the cache tag + // for the anonymous user role is never added automatically. + return array_filter(parent::getExpectedCacheTags(), function ($tag) { + return $tag !== 'config:user.role.anonymous'; + }); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6ce580d03072d94791847f63b8f4dbb8bacb9347 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonAnonTest.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Comment; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class CommentJsonAnonTest extends CommentResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + * + * Anononymous users cannot edit their own comments. + * + * @see \Drupal\comment\CommentAccessControlHandler::checkAccess + * + * Therefore we grant them the 'administer comments' permission for the + * purpose of this test. + * + * @see ::setUpAuthorization + */ + protected static $patchProtectedFieldNames = [ + 'pid', + 'entity_id', + 'changed', + 'thread', + 'entity_type', + 'field_name', + ]; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0ab5138478022cbd8ea51e099677f927d3b0915e --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Comment; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class CommentJsonBasicAuthTest extends CommentResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..967baa5d650592e33d8737f7293688f0ecc2584e --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Comment; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class CommentJsonCookieTest extends CommentResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..c4e45e1c40ecff8f329f294c44a39916abe878ae --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php @@ -0,0 +1,309 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Comment; + +use Drupal\comment\Entity\Comment; +use Drupal\comment\Entity\CommentType; +use Drupal\comment\Tests\CommentTestTrait; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; +use Drupal\user\Entity\User; +use GuzzleHttp\RequestOptions; + +abstract class CommentResourceTestBase extends EntityResourceTestBase { + + use CommentTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['comment', 'entity_test']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'comment'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = [ + 'pid', + 'entity_id', + 'uid', + 'name', + 'homepage', + 'created', + 'changed', + 'status', + 'thread', + 'entity_type', + 'field_name', + ]; + + /** + * @var \Drupal\comment\CommentInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access comments', 'view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['post comments']); + break; + case 'PATCH': + // Anononymous users are not ever allowed to edit their own comments. To + // be able to test PATCHing comments as the anonymous user, the more + // permissive 'administer comments' permission must be granted. + // @see \Drupal\comment\CommentAccessControlHandler::checkAccess + if (static::$auth) { + $this->grantPermissionsToTestedRole(['edit own comments']); + } + else { + $this->grantPermissionsToTestedRole(['administer comments']); + } + break; + case 'DELETE': + $this->grantPermissionsToTestedRole(['administer comments']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "bar" bundle for the "entity_test" entity type and create. + $bundle = 'bar'; + entity_test_create_bundle($bundle, NULL, 'entity_test'); + + // Create a comment field on this bundle. + $this->addDefaultCommentField('entity_test', 'bar', 'comment'); + + // Create a "Camelids" test entity that the comment will be assigned to. + $commented_entity = EntityTest::create(array( + 'name' => 'Camelids', + 'type' => 'bar', + )); + $commented_entity->save(); + + // Create a "Llama" comment. + $comment = Comment::create([ + 'comment_body' => [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'plain_text', + ], + 'entity_id' => $commented_entity->id(), + 'entity_type' => 'entity_test', + 'field_name' => 'comment', + ]); + $comment->setSubject('Llama') + ->setOwnerId(static::$auth ? $this->account->id() : 0) + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789); + $comment->save(); + + return $comment; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $author = User::load($this->entity->getOwnerId()); + return [ + 'cid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'comment_type' => [ + [ + 'target_id' => 'comment', + 'target_type' => 'comment_type', + 'target_uuid' => CommentType::load('comment')->uuid(), + ], + ], + 'subject' => [ + [ + 'value' => 'Llama', + ], + ], + 'status' => [ + [ + 'value' => 1, + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + 'uid' => [ + [ + 'target_id' => $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/' . $author->id(), + ], + ], + 'pid' => [], + 'entity_type' => [ + [ + 'value' => 'entity_test', + ], + ], + 'entity_id' => [ + [ + 'target_id' => '1', + 'target_type' => 'entity_test', + 'target_uuid' => EntityTest::load(1)->uuid(), + 'url' => base_path() . 'entity_test/1', + ], + ], + 'field_name' => [ + [ + 'value' => 'comment', + ], + ], + 'name' => [], + 'homepage' => [], + 'thread' => [ + [ + 'value' => '01/', + ], + ], + 'comment_body' => [ + [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'plain_text', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'comment_type' => [ + [ + 'target_id' => 'comment', + ], + ], + 'entity_type' => [ + [ + 'value' => 'entity_test', + ], + ], + 'entity_id' => [ + [ + 'target_id' => EntityTest::load(1)->id(), + ], + ], + 'field_name' => [ + [ + 'value' => 'comment', + ], + ], + 'subject' => [ + [ + 'value' => 'Dramallama', + ], + ], + 'comment_body' => [ + [ + 'value' => 'Llamas are awesome.', + 'format' => 'plain_text', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPatchEntity() { + return array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE, 'entity_id' => TRUE, 'field_name' => TRUE]); + } + + /** + * Tests POSTing a comment without critical base fields. + * + * testPost() is testing with the most minimal normalization possible: the one + * returned by ::getNormalizedPostEntity(). + * + * But Comment entities have some very special edge cases: + * - base fields that are not marked as required in + * \Drupal\comment\Entity\Comment::baseFieldDefinitions() yet in fact are + * required. + * - base fields that are marked as required, but yet can still result in + * validation errors other than "missing required field". + */ + public function testPostDxWithoutCriticalBaseFields() { + $this->initAuthentication(); + $this->provisionEntityResource(); + $this->setUpAuthorization('POST'); + + $url = $this->getPostUrl()->setOption('query', ['_format' => static::$format]); + $request_options = []; + $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType; + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('POST')); + + // DX: 422 when missing 'entity_type' field. + $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_type' => TRUE]), static::$format); + $response = $this->request('POST', $url, $request_options); + // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364. + $this->assertResourceErrorResponse(500, 'A fatal error occurred: Internal Server Error', $response); + //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response); + + // DX: 422 when missing 'entity_id' field. + $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['entity_id' => TRUE]), static::$format); + // @todo Remove the try/catch in favor of the two commented lines in + // https://www.drupal.org/node/2820364. + try { + $response = $this->request('POST', $url, $request_options); + // This happens on DrupalCI. + $this->assertSame(500, $response->getStatusCode()); + } + catch (\Exception $e) { + // This happens on Wim's local machine. + $this->assertSame("Error: Call to a member function get() on null\nDrupal\\comment\\Plugin\\Validation\\Constraint\\CommentNameConstraintValidator->getAnonymousContactDetailsSetting()() (Line: 96)\n", $e->getMessage()); + } + //$response = $this->request('POST', $url, $request_options); + //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response); + + // DX: 422 when missing 'entity_type' field. + $request_options[RequestOptions::BODY] = $this->serializer->encode(array_diff_key($this->getNormalizedPostEntity(), ['field_name' => TRUE]), static::$format); + $response = $this->request('POST', $url, $request_options); + // @todo Uncomment, remove next line in https://www.drupal.org/node/2820364. + $this->assertResourceErrorResponse(500, 'A fatal error occurred: Field is unknown.', $response); + //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nfield_name: This value should not be null.\n", $response); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..db79e6c090f5edabffd052945814c441bb3d9596 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class ConfigTestJsonAnonTest extends ConfigTestResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..309a8f12f640bdad88bae03c4fea7ff750d30130 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class ConfigTestJsonBasicAuthTest extends ConfigTestResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c243bde996603de536d736da05fdc838cdcf55a6 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class ConfigTestJsonCookieTest extends ConfigTestResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..9fe073b097b00c0e8340a88015c9737a7824e501 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php @@ -0,0 +1,73 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\ConfigTest; + +use Drupal\config_test\Entity\ConfigTest; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; + +abstract class ConfigTestResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['config_test', 'config_test_rest']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'config_test'; + + /** + * @var \Drupal\config_test\ConfigTestInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + $this->grantPermissionsToTestedRole(['view config_test']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $config_test = ConfigTest::create([ + 'id' => 'llama', + 'label' => 'Llama', + ]); + $config_test->save(); + + return $config_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $normalization = [ + 'uuid' => $this->entity->uuid(), + 'id' => 'llama', + 'weight' => 0, + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'label' => 'Llama', + 'style' => NULL, + 'size' => NULL, + 'size_value' => NULL, + 'protected_property' => NULL, + ]; + + return $normalization; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..d944c61afe76b8e802c3dbbe362719c8fc0393ae --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -0,0 +1,1088 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\EntityChangedInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Url; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\Tests\rest\Functional\ResourceTestBase; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; + +/** + * Even though there is the generic EntityResource, it's necessary for every + * entity type to have its own test, because they each have different fields, + * validation constraints, et cetera. It's not because the generic case works, + * that every case works. + * + * Furthermore, it's necessary to test every format separately, because there + * can be entity type-specific normalization or serialization problems. + * + * Subclass this for every entity type. Also respect instructions in + * \Drupal\rest\Tests\ResourceTestBase. + * + * For example, for the node test coverage, there is the (abstract) + * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which + * is then again subclassed for every authentication provider: + * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest + * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest + * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest + * But the HAL module also adds a new format ('hal_json'), so that format also + * needs test coverage (for its own peculiarities in normalization & encoding): + * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest + * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest + * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest + * + * In other words: for every entity type there should be: + * 1. an abstract subclass that includes the entity type-specific authorization + * (permissions or perhaps custom access control handling, such as node + * grants), plus + * 2. a concrete subclass extending the abstract entity type-specific subclass + * that specifies the exact @code $format @endcode, @code $mimeType @endcode, + * @code $expectedErrorMimeType @endcode and @code $auth @endcode for this + * concrete test. Usually that's all that's necessary: most concrete + * subclasses will be very thin. + * + * For every of these concrete subclasses, a comprehensive test scenario will + * run per HTTP method: + * - ::testGet() + * - ::testPost() + * - ::testPatch() + * - ::testDelete() + * + * If there is an entity type-specific edge case scenario to test, then add that + * to the entity type-specific abstract subclass. Example: + * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields + * + * If there is an entity type-specific format-specific edge case to test, then + * add that to a concrete subclass. Example: + * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames + */ +abstract class EntityResourceTestBase extends ResourceTestBase { + + /** + * The tested entity type. + * + * @var string + */ + protected static $entityTypeId = NULL; + + /** + * The fields that are protected against modification during PATCH requests. + * + * @var string[] + */ + protected static $patchProtectedFieldNames; + + /** + * Optionally specify which field is the 'label' field. Some entities specify + * a 'label_callback', but not a 'label' entity key. For example: User. + * + * @see ::getInvalidNormalizedEntityToCreate + * + * @var string|null + */ + protected static $labelFieldName = NULL; + + /** + * The entity ID for the first created entity in testPost(). + * + * The default value of 2 should work for most content entities. + * + * @see ::testPost() + * + * @var string|int + */ + protected static $firstCreatedEntityId = 2; + + /** + * The entity ID for the second created entity in testPost(). + * + * The default value of 3 should work for most content entities. + * + * @see ::testPost() + * + * @var string|int + */ + protected static $secondCreatedEntityId = 3; + + /** + * @var \GuzzleHttp\ClientInterface + */ + protected $httpClient; + + /** + * The main entity used for testing. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entity; + + /** + * The entity storage. + * + * @var \Drupal\Core\Entity\EntityStorageInterface + */ + protected $entityStorage; + + /** + * Modules to install. + * + * @var array + */ + public static $modules = ['rest_test', 'text']; + + /** + * {@inheritdoc} + */ + protected function provisionEntityResource() { + // It's possible to not have any authentication providers enabled, when + // testing public (anonymous) usage of a REST resource. + $auth = isset(static::$auth) ? [static::$auth] : []; + $this->provisionResource('entity.' . static::$entityTypeId, [static::$format], $auth); + } + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + $this->serializer = $this->container->get('serializer'); + $this->entityStorage = $this->container->get('entity_type.manager') + ->getStorage(static::$entityTypeId); + + // Set up a HTTP client that accepts relative URLs. + $this->httpClient = $this->container->get('http_client_factory') + ->fromOptions(['base_uri' => $this->baseUrl]); + + // Create an entity. + $this->entity = $this->createEntity(); + + if ($this->entity instanceof FieldableEntityInterface) { + // Add access-protected field. + FieldStorageConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test', + 'type' => 'text', + ]) + ->setCardinality(1) + ->save(); + FieldConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test', + 'bundle' => $this->entity->bundle(), + ]) + ->setLabel('Test field') + ->setTranslatable(FALSE) + ->save(); + + // Reload entity so that it has the new field. + $this->entity = $this->entityStorage->loadUnchanged($this->entity->id()); + + // Set a default value on the field. + $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']); + // @todo Remove in this if-test in https://www.drupal.org/node/2808335. + if ($this->entity instanceof EntityChangedInterface) { + $changed = $this->entity->getChangedTime(); + $this->entity->setChangedTime(42); + $this->entity->save(); + $this->entity->setChangedTime($changed); + } + $this->entity->save(); + } + + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + } + + /** + * Creates the entity to be tested. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity to be tested. + */ + abstract protected function createEntity(); + + /** + * Returns the expected normalization of the entity. + * + * @see ::createEntity() + * + * @return array + */ + abstract protected function getExpectedNormalizedEntity(); + + /** + * Returns the normalized POST entity. + * + * @see ::testPost + * + * @return array + */ + abstract protected function getNormalizedPostEntity(); + + /** + * Returns the normalized PATCH entity. + * + * By default, reuses ::getNormalizedPostEntity(), which works fine for most + * entity types. A counterexample: the 'comment' entity type. + * + * @see ::testPatch + * + * @return array + */ + protected function getNormalizedPatchEntity() { + return $this->getNormalizedPostEntity(); + } + + /** + * The expected cache tags for the GET/HEAD response of the test entity. + * + * @see ::testGet + * + * @return string[] + */ + protected function getExpectedCacheTags() { + $expected_cache_tags = [ + 'config:rest.resource.entity.' . static::$entityTypeId, + ]; + if (!static::$auth) { + $expected_cache_tags[] = 'config:user.role.anonymous'; + } + return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags()); + } + + /** + * The expected cache contexts for the GET/HEAD response of the test entity. + * + * @see ::testGet + * + * @return string[] + */ + protected function getExpectedCacheContexts() { + return [ + 'user.permissions', + ]; + } + + /** + * Test a GET request for an entity, plus edge cases to ensure good DX. + */ + public function testGet() { + $this->initAuthentication(); + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getUrl(); + $request_options = []; + + + // DX: 404 when resource not provisioned, 403 if canonical route. HTML + // response because missing ?_format query string. + $response = $this->request('GET', $url, $request_options); + $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML + // response because ?_format query string is present. + $response = $this->request('GET', $url, $request_options); + if ($has_canonical_url) { + $this->assertResourceErrorResponse(403, '', $response); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $this->provisionEntityResource(); + // Simulate the developer again forgetting the ?_format query string. + $url->setOption('query', []); + + + + // DX: 406 when ?_format is missing, except when requesting a canonical HTML + // route. + $response = $this->request('GET', $url, $request_options); + if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) { + $this->assertSame(403, $response->getStatusCode()); + } + else { + $this->assert406Response($response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: forgetting authentication: authentication provider-specific error + // response. + if (static::$auth) { + $response = $this->request('GET', $url, $request_options); + $this->assertResponseWhenMissingAuthentication($response); + } + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET')); + + + // DX: 403 when unauthorized. + $response = $this->request('GET', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('GET'); + + + // 200 for well-formed HEAD request. + $response = $this->request('HEAD', $url, $request_options); + $this->assertResourceResponse(200, '', $response); + if (!$this->account) { + $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache')); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + $head_headers = $response->getHeaders(); + + // 200 for well-formed GET request. Page Cache hit because of HEAD request. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + if (!static::$auth) { + $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache')); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + $cache_tags_header_value = $response->getHeader('X-Drupal-Cache-Tags')[0]; + $this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value)); + $cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0]; + $this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value)); + // Comparing the exact serialization is pointless, because the order of + // fields does not matter (at least not yet). That's why we only compare the + // normalized entity with the decoded response: it's comparing PHP arrays + // instead of strings. + $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string) $response->getBody(), static::$format)); + // Not only assert the normalization, also assert deserialization of the + // response results in the expected object. + $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format); + $this->assertSame($unserialized->uuid(), $this->entity->uuid()); + $get_headers = $response->getHeaders(); + + // Verify that the GET and HEAD responses are the same. The only difference + // is that there's no body. + $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache']; + foreach ($ignored_headers as $ignored_header) { + unset($head_headers[$ignored_header]); + unset($get_headers[$ignored_header]); + } + $this->assertSame($get_headers, $head_headers); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + + + // DX: 403 when unauthorized. + $response = $this->request('GET', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]); + + + // 200 for well-formed request. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + $url->setOption('query', ['_format' => 'non_existing_format']); + + + // DX: 406 when requesting unsupported format. + $response = $this->request('GET', $url, $request_options); + $this->assert406Response($response); + $this->assertNotSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + + + $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType; + + + // DX: 406 when requesting unsupported format but specifying Accept header. + // @todo Update in https://www.drupal.org/node/2825347. + $response = $this->request('GET', $url, $request_options); + $this->assert406Response($response); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + + + $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format); + $url->setRouteParameter(static::$entityTypeId, 987654321); + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when GETting non-existing entity. + $response = $this->request('GET', $url, $request_options); + $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); + $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET.' . static::$format . '")'; + $this->assertResourceErrorResponse(404, $message, $response); + } + + /** + * Tests a POST request for an entity, plus edge cases to ensure good DX. + */ + public function testPost() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); + return; + } + + $this->initAuthentication(); + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + + // Try with all of the following request bodies. + $unparseable_request_body = '!{>}<'; + $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format); + // @todo Change to ['uuid' => UUID] in https://www.drupal.org/node/2820743. + $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [['value' => $this->randomMachineName(129)]]], static::$format); + $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getPostUrl(); + $request_options = []; + + + // DX: 404 when resource not provisioned, but HTML if canonical route. + $response = $this->request('POST', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 404 when resource not provisioned. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getPostUrl()->setAbsolute()->toString()) . '"', $response); + + + $this->provisionEntityResource(); + // Simulate the developer again forgetting the ?_format query string. + $url->setOption('query', []); + + + // DX: 415 when no Content-Type request header, but HTML if canonical route. + $response = $this->request('POST', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(415, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertContains(htmlspecialchars('No "Content-Type" request header specified'), $response->getBody()->getContents()); + } + else { + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 415 when no Content-Type request header. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // DX: 400 when no request body. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(400, 'No entity content received.', $response); + + + $request_options[RequestOptions::BODY] = $unparseable_request_body; + + + // DX: 400 when unparseable request body. + $response = $this->request('POST', $url, $request_options); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853. + // $this->assertResourceErrorResponse(400, 'Syntax error', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody()); + + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + + + if (static::$auth) { + // DX: forgetting authentication: authentication provider-specific error + // response. + $response = $this->request('POST', $url, $request_options); + $this->assertResponseWhenMissingAuthentication($response); + } + + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('POST'); + + + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $label_field_capitalized = ucfirst($label_field); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755. + // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: <em class=\"placeholder\">Title</em>: 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: <em class=\"placeholder\">$label_field_capitalized</em>: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; + + + // DX: 422 when invalid entity: UUID field too long. + $response = $this->request('POST', $url, $request_options); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755. + // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: <em class=\"placeholder\">UUID</em>: 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: <em class=\"placeholder\">UUID</em>: may not be longer than 128 characters.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; + + + // DX: 403 when entity contains field without 'edit' access. + $response = $this->request('POST', $url, $request_options); + // @todo Add trailing period in https://www.drupal.org/node/2821013. + $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'", $response); + + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + + // Before sending a well-formed request, allow the normalization and + // authentication provider edge cases to also be tested. + $this->assertNormalizationEdgeCases('POST', $url, $request_options); + $this->assertAuthenticationEdgeCases('POST', $url, $request_options); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; + + + // DX: 415 when request body in existing but not allowed format. + $response = $this->request('POST', $url, $request_options); + // @todo Update this in https://www.drupal.org/node/2826407. Also move it + // higher, before the "no request body" test. That's impossible right now, + // because the format validation happens too late. + $this->assertResourceErrorResponse(415, '', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $this->assertSame([str_replace($this->entity->id(), static::$firstCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location')); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2; + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]); + + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $this->assertSame([str_replace($this->entity->id(), static::$secondCreatedEntityId, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location')); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + + /** + * Tests a PATCH request for an entity, plus edge cases to ensure good DX. + */ + public function testPatch() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.'); + return; + } + + $this->initAuthentication(); + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + + // Try with all of the following request bodies. + $unparseable_request_body = '!{>}<'; + $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format); + $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getUrl(); + $request_options = []; + + + // DX: 405 when resource not provisioned, but HTML if canonical route. + $response = $this->request('PATCH', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 405 when resource not provisioned. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response); + + + $this->provisionEntityResource(); + // Simulate the developer again forgetting the ?_format query string. + $url->setOption('query', []); + + + // DX: 415 when no Content-Type request header, but HTML if canonical route. + $response = $this->request('PATCH', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(415, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertTrue(FALSE !== strpos($response->getBody()->getContents(), htmlspecialchars('No "Content-Type" request header specified'))); + } + else { + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 415 when no Content-Type request header. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // DX: 400 when no request body. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(400, 'No entity content received.', $response); + + + $request_options[RequestOptions::BODY] = $unparseable_request_body; + + + // DX: 400 when unparseable request body. + $response = $this->request('PATCH', $url, $request_options); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813853. + // $this->assertResourceErrorResponse(400, 'Syntax error', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody()); + + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + + + if (static::$auth) { + // DX: forgetting authentication: authentication provider-specific error + // response. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResponseWhenMissingAuthentication($response); + } + + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH')); + + + // DX: 403 when unauthorized. + $response = $this->request('PATCH', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('PATCH'); + + + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('PATCH', $url, $request_options); + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $label_field_capitalized = ucfirst($label_field); + // @todo Uncomment, remove next 3 in https://www.drupal.org/node/2813755. + // $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: <em class=\"placeholder\">Title</em>: 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: <em class=\"placeholder\">$label_field_capitalized</em>: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; + + + // DX: 403 when entity contains field without 'edit' access. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response); + + + // DX: 403 when sending PATCH request with read-only fields. + // First send all fields (the "maximum normalization"). Assert the expected + // error message for the first PATCH-protected field. Remove that field from + // the normalization, send another request, assert the next PATCH-protected + // field error message. And so on. + $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format); + for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) { + $max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i)); + $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response); + } + + // 200 for well-formed request that sends the maximum number of fields. + $max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames); + $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format); + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + + // Before sending a well-formed request, allow the normalization and + // authentication provider edge cases to also be tested. + $this->assertNormalizationEdgeCases('PATCH', $url, $request_options); + $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; + + + // DX: 415 when request body in existing but not allowed format. + $response = $this->request('PATCH', $url, $request_options); + // @todo Update this in https://www.drupal.org/node/2826407. Also move it + // higher, before the "no request body" test. That's impossible right now, + // because the format validation happens too late. + $this->assertResourceErrorResponse(415, '', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // 200 for well-formed request. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + // Ensure that fields do not get deleted if they're not present in the PATCH + // request. Test this using the configurable field that we added, but which + // is not sent in the PATCH request. + $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test')->value); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2; + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + + + // DX: 403 when unauthorized. + $response = $this->request('PATCH', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]); + + + // 200 for well-formed request. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + + /** + * Tests a DELETE request for an entity, plus edge cases to ensure good DX. + */ + public function testDelete() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.'); + return; + } + + $this->initAuthentication(); + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = $this->getUrl(); + $request_options = []; + + + // DX: 405 when resource not provisioned, but HTML if canonical route. + $response = $this->request('DELETE', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + else { + $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response); + } + + + $url->setOption('query', ['_format' => static::$format]); + + + // DX: 405 when resource not provisioned. + $response = $this->request('DELETE', $url, $request_options); + $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response); + + + $this->provisionEntityResource(); + + + if (static::$auth) { + // DX: forgetting authentication: authentication provider-specific error + // response. + $response = $this->request('DELETE', $url, $request_options); + $this->assertResponseWhenMissingAuthentication($response); + } + + + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH')); + + + // DX: 403 when unauthorized. + $response = $this->request('DELETE', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('DELETE'); + + + // Before sending a well-formed request, allow the authentication provider's + // edge cases to also be tested. + $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options); + + + // 204 for well-formed request. + $response = $this->request('DELETE', $url, $request_options); + $this->assertSame(204, $response->getStatusCode()); + // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed. + // $this->assertSame(FALSE, $response->hasHeader('Content-Type')); + $this->assertSame('', $response->getBody()->getContents()); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + + + $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + $this->entity = $this->createEntity(); + $url = $this->getUrl()->setOption('query', $url->getOption('query')); + + + // DX: 403 when unauthorized. + $response = $this->request('DELETE', $url, $request_options); + // @todo Update the message in https://www.drupal.org/node/2808233. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]); + + + // 204 for well-formed request. + $response = $this->request('DELETE', $url, $request_options); + $this->assertSame(204, $response->getStatusCode()); + // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed. + // $this->assertSame(FALSE, $response->hasHeader('Content-Type')); + $this->assertSame('', $response->getBody()->getContents()); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity + // types with bundles MUST send their bundle field to be denormalizable. + $entity_type = $this->entity->getEntityType(); + if ($entity_type->hasKey('bundle')) { + $bundle_field_name = $this->entity->getEntityType()->getKey('bundle'); + $normalization = $this->getNormalizedPostEntity(); + + // The bundle type itself can be validated only if there's a bundle entity + // type. + if ($entity_type->getBundleEntityType()) { + $normalization[$bundle_field_name] = 'bad_bundle_name'; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // DX: 400 when incorrect entity type bundle is specified. + $response = $this->request($method, $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands. + // $this->assertResourceErrorResponse(400, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => '"bad_bundle_name" is not a valid bundle type for denormalization.'], static::$format), (string) $response->getBody()); + } + + + unset($normalization[$bundle_field_name]); + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // DX: 400 when no entity type bundle is specified. + $response = $this->request($method, $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands. + // $this->assertResourceErrorResponse(400, 'A string must be provided as a bundle value.', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'A string must be provided as a bundle value.'], static::$format), (string) $response->getBody()); + } + } + + /** + * Gets an entity resource's GET/PATCH/DELETE URL. + * + * @return \Drupal\Core\Url + * The URL to GET/PATCH/DELETE. + */ + protected function getUrl() { + $has_canonical_url = $this->entity->hasLinkTemplate('canonical'); + return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id()); + } + + /** + * Gets an entity resource's POST URL. + * + * @return \Drupal\Core\Url + * The URL to POST to. + */ + protected function getPostUrl() { + $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create'); + return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId); + } + + /** + * Makes the given entity normalization invalid. + * + * @param array $normalization + * An entity normalization. + * + * @return array + * The updated entity normalization, now invalid. + */ + protected function makeNormalizationInvalid(array $normalization) { + // Add a second label to this entity to make it invalid. + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $normalization[$label_field][1]['value'] = 'Second Title'; + + return $normalization; + } + + /** + * Removes fields from a normalization. + * + * @param array $normalization + * An entity normalization. + * @param string[] $field_names + * The field names to remove from the entity normalization. + * + * @return array + * The updated entity normalization. + * + * @see ::testPatch + */ + protected function removeFieldsFromNormalization(array $normalization, $field_names) { + return array_diff_key($normalization, array_flip($field_names)); + } + + /** + * Asserts a 406 response… or in some cases a 403 response, because weirdness. + * + * Asserting a 406 response should be easy, but it's not, due to bugs. + * + * Drupal returns a 403 response instead of a 406 response when: + * - there is a canonical route, i.e. one that serves HTML + * - unless the user is logged in with any non-global authentication provider, + * because then they tried to access a route that requires the user to be + * authenticated, but they used an authentication provider that is only + * accepted for specific routes, and HTML routes never have such specific + * authentication providers specified. (By default, only 'cookie' is a + * global authentication provider.) + * + * @todo Remove this in https://www.drupal.org/node/2805279. + * + * @param \Psr\Http\Message\ResponseInterface $response + * The response to assert. + */ + protected function assert406Response(ResponseInterface $response) { + if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) { + $this->assertSame(403, $response->getStatusCode()); + } + else { + // This is the desired response. + $this->assertSame(406, $response->getStatusCode()); + } + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a7e442055dee74f88608e09675ac49f610444823 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class EntityTestJsonAnonTest extends EntityTestResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..be75784104d270ae680a5bef0c41f3478046650b --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class EntityTestJsonBasicAuthTest extends EntityTestResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..84079220dce94364c51637b6800afcf433ee26bc --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class EntityTestJsonCookieTest extends EntityTestResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..da86d0006068b1982b19d3f1d0d4de8b1557000a --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php @@ -0,0 +1,127 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\EntityTest; + +use Drupal\entity_test\Entity\EntityTest; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; +use Drupal\user\Entity\User; + +abstract class EntityTestResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['entity_test']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'entity_test'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = []; + + /** + * @var \Drupal\entity_test\Entity\EntityTest + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities']); + break; + case 'PATCH': + case 'DELETE': + $this->grantPermissionsToTestedRole(['administer entity_test content']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => 'entity_test', + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $author = User::load(0); + $normalization = [ + 'uuid' => [ + [ + 'value' => $this->entity->uuid() + ] + ], + 'id' => [ + [ + 'value' => '1', + ], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'type' => [ + [ + 'value' => 'entity_test', + ] + ], + 'name' => [ + [ + 'value' => 'Llama', + ] + ], + 'created' => [ + [ + 'value' => $this->entity->get('created')->value, + ] + ], + 'user_id' => [ + [ + 'target_id' => $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => $author->toUrl()->toString(), + ] + ], + 'field_test_text' => [], + ]; + + return $normalization; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'type' => 'entity_test', + 'name' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..24d47c495838a3265a5e16d5ee670a990d6a7693 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Node; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class NodeJsonAnonTest extends NodeResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..b71d1d57a6e4604e693df4fb73b45ec1a05c5f37 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Node; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class NodeJsonBasicAuthTest extends NodeResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f0bd6534b65bef3d8ec457bf477cf227de1b3a0f --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Node; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class NodeJsonCookieTest extends NodeResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..d7651c4b9094c70a8c00b2d25a97df42da625aa3 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -0,0 +1,196 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Node; + +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; +use Drupal\user\Entity\User; + +abstract class NodeResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['node']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'node'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = [ + 'uid', + 'created', + 'changed', + 'promote', + 'sticky', + 'revision_timestamp', + 'revision_uid', + ]; + + /** + * @var \Drupal\node\NodeInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['access content', 'create camelids content']); + break; + case 'PATCH': + $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']); + break; + case 'DELETE': + $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + if (!NodeType::load('camelids')) { + // Create a "Camelids" node type. + NodeType::create([ + 'name' => 'Camelids', + 'type' => 'camelids', + ])->save(); + } + + // Create a "Llama" node. + $node = Node::create(['type' => 'camelids']); + $node->setTitle('Llama') + ->setOwnerId(static::$auth ? $this->account->id() : 0) + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789) + ->setRevisionCreationTime(123456789) + ->save(); + + return $node; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $author = User::load($this->entity->getOwnerId()); + return [ + 'nid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'vid' => [ + ['value' => 1], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'type' => [ + [ + 'target_id' => 'camelids', + 'target_type' => 'node_type', + 'target_uuid' => NodeType::load('camelids')->uuid(), + ], + ], + 'title' => [ + [ + 'value' => 'Llama', + ], + ], + 'status' => [ + [ + 'value' => 1, + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'promote' => [ + [ + 'value' => 1, + ], + ], + 'sticky' => [ + [ + 'value' => '0', + ], + ], + 'revision_timestamp' => [ + [ + 'value' => '123456789', + ], + ], + 'revision_translation_affected' => [ + [ + 'value' => TRUE, + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + 'uid' => [ + [ + 'target_id' => $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/' . $author->id(), + ], + ], + 'revision_uid' => [ + [ + 'target_id' => $author->id(), + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => base_path() . 'user/' . $author->id(), + ], + ], + 'revision_log' => [ + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'type' => [ + [ + 'target_id' => 'camelids', + ], + ], + 'title' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..3ec96eb7a56f1506eff12a48953fbb9ef20a4cf5 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class RoleJsonAnonTest extends RoleResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..75fcd080028dc55ede68de65c9620a6d6a8a37f9 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonBasicAuthTest.php @@ -0,0 +1,48 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Psr\Http\Message\ResponseInterface; + +/** + * @group rest + */ +class RoleJsonBasicAuthTest extends RoleResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + /** + * {@inheritdoc} + */ + protected function assertResponseWhenMissingAuthentication(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/RoleJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..9f4ec073c22e490580e86e26322510a4927cab1d --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class RoleJsonCookieTest extends RoleResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..ee719c4b5696b258ece5e4a2f09cccb960dc1013 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php @@ -0,0 +1,69 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Role; + +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; +use Drupal\user\Entity\Role; + +abstract class RoleResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['user']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'user_role'; + + /** + * @var \Drupal\user\RoleInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + $this->grantPermissionsToTestedRole(['administer permissions']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $role = Role::create([ + 'id' => 'llama', + 'name' => $this->randomString(), + ]); + $role->save(); + + return $role; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'uuid' => $this->entity->uuid(), + 'weight' => 2, + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'id' => 'llama', + 'label' => NULL, + 'is_admin' => NULL, + 'permissions' => [], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6e01c0314936912c14930030882e85b480fe4b9a --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Term; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class TermJsonAnonTest extends TermResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} 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 0000000000000000000000000000000000000000..6befa3a9ad3c51a5dcb57c95024028b13b3449b3 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Term; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class TermJsonBasicAuthTest extends TermResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6268a747ff7b20d7e73137e25ae36694b4e17bb3 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Term; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class TermJsonCookieTest extends TermResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} 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 0000000000000000000000000000000000000000..b6dce4f4b2de375e9e3c52ef6c1eda122590117e --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php @@ -0,0 +1,140 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Term; + +use Drupal\taxonomy\Entity\Term; +use Drupal\taxonomy\Entity\Vocabulary; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; + +abstract class TermResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['taxonomy']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'taxonomy_term'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = [ + 'changed', + ]; + + /** + * @var \Drupal\taxonomy\TermInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + case 'POST': + case 'PATCH': + case 'DELETE': + // @todo Update once https://www.drupal.org/node/2824408 lands. + $this->grantPermissionsToTestedRole(['administer taxonomy']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $vocabulary = Vocabulary::load('camelids'); + if (!$vocabulary) { + // Create a "Camelids" vocabulary. + $vocabulary = Vocabulary::create([ + 'name' => 'Camelids', + 'vid' => 'camelids', + ]); + $vocabulary->save(); + } + + // Create a "Llama" taxonomy term. + $term = Term::create(['vid' => $vocabulary->id()]) + ->setName('Llama') + ->setChangedTime(123456789); + $term->save(); + + return $term; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'tid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'vid' => [ + [ + 'target_id' => 'camelids', + 'target_type' => 'taxonomy_vocabulary', + 'target_uuid' => Vocabulary::load('camelids')->uuid(), + ], + ], + 'name' => [ + ['value' => 'Llama'], + ], + 'description' => [ + [ + 'value' => NULL, + 'format' => NULL, + ], + ], + 'parent' => [], + 'weight' => [ + ['value' => 0], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'vid' => [ + [ + 'target_id' => 'camelids', + ], + ], + 'name' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a20aff8346947cfc22be8cd497644e7b1f79a65d --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonAnonTest.php @@ -0,0 +1,29 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\User; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class UserJsonAnonTest extends UserResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f67e0a6790edb4248d159afabeef8d00a829de3e --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\User; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class UserJsonBasicAuthTest extends UserResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..12ee0c919c2c0c9757d9420d39f9a13cbbd28489 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\User; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class UserJsonCookieTest extends UserResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..31cf083af5c9aeec0a0c48c9b7a2a03d19eed1db --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php @@ -0,0 +1,232 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\User; + +use Drupal\Core\Url; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; +use Drupal\user\Entity\User; +use GuzzleHttp\RequestOptions; + +abstract class UserResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['user']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'user'; + + /** + * {@inheritdoc} + */ + protected static $patchProtectedFieldNames = [ + 'changed', + ]; + + /** + * @var \Drupal\user\UserInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected static $labelFieldName = 'name'; + + /** + * {@inheritdoc} + */ + protected static $firstCreatedEntityId = 4; + + /** + * {@inheritdoc} + */ + protected static $secondCreatedEntityId = 5; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access user profiles']); + break; + case 'POST': + case 'PATCH': + case 'DELETE': + $this->grantPermissionsToTestedRole(['administer users']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + // Create a "Llama" user. + $user = User::create(['created' => 123456789]); + $user->setUsername('Llama') + ->setChangedTime(123456789) + ->activate() + ->save(); + + return $user; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'uid' => [ + ['value' => '3'], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'name' => [ + [ + 'value' => 'Llama', + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'name' => [ + [ + 'value' => 'Dramallama ' . $this->randomMachineName(), + ], + ], + ]; + } + + /** + * Tests PATCHing security-sensitive base fields of the logged in account. + */ + public function testPatchDxForSecuritySensitiveBaseFields() { + // The anonymous user is never allowed to modify itself. + if (!static::$auth) { + $this->markTestSkipped(); + } + + $this->initAuthentication(); + $this->provisionEntityResource(); + $this->setUpAuthorization('PATCH'); + + /** @var \Drupal\user\UserInterface $user */ + $user = static::$auth ? $this->account : User::load(0); + $original_normalization = array_diff_key($this->serializer->normalize($user, static::$format), ['changed' => TRUE]); + + + // Since this test must be performed by the user that is being modified, + // we cannot use $this->getUrl(). + $url = $user->toUrl()->setOption('query', ['_format' => static::$format]); + $request_options = [ + RequestOptions::HEADERS => ['Content-Type' => static::$mimeType], + ]; + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH')); + + + // Test case 1: changing email. + $normalization = $original_normalization; + $normalization['mail'] = [['value' => 'new-email@example.com']]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // DX: 422 when changing email without providing the password. + $response = $this->request('PATCH', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. + // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n"], static::$format), (string) $response->getBody()); + + + $normalization['pass'] = [['existing' => 'wrong']]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + // DX: 422 when changing email while providing a wrong password. + $response = $this->request('PATCH', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. + // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Email</em>.\n"], static::$format), (string) $response->getBody()); + + + $normalization['pass'] = [['existing' => $this->account->passRaw]]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // 200 for well-formed request. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + // Test case 2: changing password. + $normalization = $original_normalization; + $new_password = $this->randomString(); + $normalization['pass'] = [['value' => $new_password]]; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // DX: 422 when changing password without providing the current password. + $response = $this->request('PATCH', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. + // $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Password</em>.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the <em class=\"placeholder\">Password</em>.\n"], static::$format), (string) $response->getBody()); + + + $normalization['pass'][0]['existing'] = $this->account->pass_raw; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + + + // 200 for well-formed request. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + + + // Verify that we can log in with the new password. + $request_body = [ + 'name' => $user->getAccountName(), + 'pass' => $new_password, + ]; + $request_options = [ + RequestOptions::HEADERS => [], + RequestOptions::BODY => $this->serializer->encode($request_body, 'json'), + ]; + $response = $this->httpClient->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json')->toString(), $request_options); + $this->assertSame(200, $response->getStatusCode()); + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4e29106996f165cf8b64d9e3bdd0cedfc6e478c7 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonAnonTest.php @@ -0,0 +1,37 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; + +/** + * @group rest + */ +class VocabularyJsonAnonTest extends VocabularyResourceTestBase { + + use AnonResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * Disable the GET test coverage due to bug in taxonomy module. + * @todo Fix in https://www.drupal.org/node/2805281: remove this override. + */ + public function testGet() { + $this->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 0000000000000000000000000000000000000000..106005006eb21e95ca67e150655d5ecfdb312c2f --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonBasicAuthTest.php @@ -0,0 +1,45 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait; +use Drupal\Tests\rest\Functional\JsonBasicAuthWorkaroundFor2805281Trait; + +/** + * @group rest + */ +class VocabularyJsonBasicAuthTest extends VocabularyResourceTestBase { + + use BasicAuthResourceTestTrait; + + /** + * {@inheritdoc} + */ + public static $modules = ['basic_auth']; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'basic_auth'; + + // @todo Fix in https://www.drupal.org/node/2805281: remove this trait usage. + use JsonBasicAuthWorkaroundFor2805281Trait { + JsonBasicAuthWorkaroundFor2805281Trait::assertResponseWhenMissingAuthentication insteadof BasicAuthResourceTestTrait; + } + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f8939c68bfa16b1eee600631846b81f7ac0f8da8 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyJsonCookieTest.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary; + +use Drupal\Tests\rest\Functional\CookieResourceTestTrait; + +/** + * @group rest + */ +class VocabularyJsonCookieTest extends VocabularyResourceTestBase { + + use CookieResourceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $format = 'json'; + + /** + * {@inheritdoc} + */ + protected static $mimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * {@inheritdoc} + */ + protected static $auth = 'cookie'; + +} diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..abada74b35fdc47333f59dad852e21a0d3e16257 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php @@ -0,0 +1,69 @@ +<?php + +namespace Drupal\Tests\rest\Functional\EntityResource\Vocabulary; + +use Drupal\taxonomy\Entity\Vocabulary; +use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase; + +abstract class VocabularyResourceTestBase extends EntityResourceTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['taxonomy']; + + /** + * {@inheritdoc} + */ + protected static $entityTypeId = 'taxonomy_vocabulary'; + + /** + * @var \Drupal\taxonomy\VocabularyInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + $this->grantPermissionsToTestedRole(['administer taxonomy']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $vocabulary = Vocabulary::create([ + 'name' => 'Llama', + 'vid' => 'llama', + ]); + $vocabulary->save(); + + return $vocabulary; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return [ + 'uuid' => $this->entity->uuid(), + 'vid' => 'llama', + 'langcode' => 'en', + 'status' => TRUE, + 'dependencies' => [], + 'name' => 'Llama', + 'description' => NULL, + 'hierarchy' => 0, + 'weight' => 0, + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + // @todo Update in https://www.drupal.org/node/2300677. + } + +} diff --git a/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php new file mode 100644 index 0000000000000000000000000000000000000000..495bf5ae745c4c708fc5d8705cb9636d50110511 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/JsonBasicAuthWorkaroundFor2805281Trait.php @@ -0,0 +1,25 @@ +<?php + +namespace Drupal\Tests\rest\Functional; + +use Psr\Http\Message\ResponseInterface; + +trait JsonBasicAuthWorkaroundFor2805281Trait { + + /** + * {@inheritdoc} + * + * Note that strange 'A fatal error occurred: ' prefix, that should not exist. + * + * @todo Fix in https://www.drupal.org/node/2805281: remove this trait. + */ + protected function assertResponseWhenMissingAuthentication(ResponseInterface $response) { + $this->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 0000000000000000000000000000000000000000..f1f0458dca6cc51b722f9f03efb04081824d8707 --- /dev/null +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -0,0 +1,349 @@ +<?php + +namespace Drupal\Tests\rest\Functional; + +use Drupal\Core\Url; +use Drupal\rest\RestResourceConfigInterface; +use Drupal\Tests\BrowserTestBase; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; + +/** + * Subclass this for every REST resource, every format and every auth provider. + * + * For more guidance see + * \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase + * which has recommendations for testing the + * \Drupal\rest\Plugin\rest\resource\EntityResource REST resource for every + * format and every auth provider. It's a special case (because that single REST + * resource generates supports not just one thing, but many things — multiple + * entity types), but the same principles apply. + */ +abstract class ResourceTestBase extends BrowserTestBase { + + /** + * The format to use in this test. + * + * A format is the combination of a certain normalizer and a certain + * serializer. + * + * @see https://www.drupal.org/developing/api/8/serialization + * + * (The default is 'json' because that doesn't depend on any module.) + * + * @var string + */ + protected static $format = 'json'; + + /** + * The MIME type that corresponds to $format. + * + * (Sadly this cannot be computed automatically yet.) + * + * @var string + */ + protected static $mimeType = 'application/json'; + + /** + * The expected MIME type in case of 4xx error responses. + * + * (Can be different, when $mimeType for example encodes a particular + * normalization, such as 'application/hal+json': its error response MIME + * type is 'application/json'.) + * + * @var string + */ + protected static $expectedErrorMimeType = 'application/json'; + + /** + * The authentication mechanism to use in this test. + * + * (The default is 'cookie' because that doesn't depend on any module.) + * + * @var string + */ + protected static $auth = FALSE; + + /** + * The account to use for authentication, if any. + * + * @var null|\Drupal\Core\Session\AccountInterface + */ + protected $account = NULL; + + /** + * The REST resource config entity storage. + * + * @var \Drupal\Core\Entity\EntityStorageInterface + */ + protected $resourceConfigStorage; + + /** + * The serializer service. + * + * @var \Symfony\Component\Serializer\Serializer + */ + protected $serializer; + + /** + * Modules to install. + * + * @var array + */ + public static $modules = ['rest']; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + // Ensure the anonymous user role has no permissions at all. + $user_role = Role::load(RoleInterface::ANONYMOUS_ID); + foreach ($user_role->getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert('[] === $user_role->getPermissions()', 'The anonymous user role has no permissions at all.'); + + if (static::$auth !== FALSE) { + // Ensure the authenticated user role has no permissions at all. + $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); + foreach ($user_role->getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert('[] === $user_role->getPermissions()', 'The authenticated user role has no permissions at all.'); + + // Create an account. + $this->account = $this->createUser(); + } + else { + // Otherwise, also create an account, so that any test involving User + // entities will have the same user IDs regardless of authentication. + $this->createUser(); + } + + $this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config'); + + // Ensure there's a clean slate: delete all REST resource config entities. + $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple()); + } + + /** + * Provisions a REST resource. + * + * @param string $resource_type + * The resource type (REST resource plugin ID). + * @param string[] $formats + * The allowed formats for this resource. + * @param string[] $authentication + * The allowed authentication providers for this resource. + */ + protected function provisionResource($resource_type, $formats = [], $authentication = []) { + $this->resourceConfigStorage->create([ + 'id' => $resource_type, + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'], + 'formats' => $formats, + 'authentication' => $authentication, + ] + ])->save(); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); + } + + /** + * Sets up the necessary authorization. + * + * In case of a test verifying publicly accessible REST resources: grant + * permissions to the anonymous user role. + * + * In case of a test verifying behavior when using a particular authentication + * provider: create a user with a particular set of permissions. + * + * Because of the $method parameter, it's possible to first set up + * authentication for only GET, then add POST, et cetera. This then also + * allows for verifying a 403 in case of missing authorization. + * + * @param string $method + * The HTTP method for which to set up authentication. + * + * @see ::grantPermissionsToAnonymousRole() + * @see ::grantPermissionsToAuthenticatedRole() + */ + abstract protected function setUpAuthorization($method); + + /** + * Verifies the error response in case of missing authentication. + */ + abstract protected function assertResponseWhenMissingAuthentication(ResponseInterface $response); + + /** + * Asserts normalization-specific edge cases. + * + * (Should be called before sending a well-formed request.) + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + */ + abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options); + + /** + * Asserts authentication provider-specific edge cases. + * + * (Should be called before sending a well-formed request.) + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + */ + abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options); + + /** + * Initializes authentication. + * + * E.g. for cookie authentication, we first need to get a cookie. + */ + protected function initAuthentication() {} + + /** + * Returns Guzzle request options for authentication. + * + * @param string $method + * The HTTP method for this authenticated request. + * + * @return array + * Guzzle request options to use for authentication. + * + * @see \GuzzleHttp\ClientInterface::request() + */ + protected function getAuthenticationRequestOptions($method) { + return []; + } + + /** + * Grants permissions to the anonymous role. + * + * @param string[] $permissions + * Permissions to grant. + */ + protected function grantPermissionsToAnonymousRole(array $permissions) { + $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions); + } + + /** + * Grants permissions to the authenticated role. + * + * @param string[] $permissions + * Permissions to grant. + */ + protected function grantPermissionsToAuthenticatedRole(array $permissions) { + $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions); + } + + /** + * Grants permissions to the tested role: anonymous or authenticated. + * + * @param string[] $permissions + * Permissions to grant. + * + * @see ::grantPermissionsToAuthenticatedRole() + * @see ::grantPermissionsToAnonymousRole() + */ + protected function grantPermissionsToTestedRole(array $permissions) { + if (static::$auth) { + $this->grantPermissionsToAuthenticatedRole($permissions); + } + else { + $this->grantPermissionsToAnonymousRole($permissions); + } + } + + /** + * Performs a HTTP request. Wraps the Guzzle HTTP client. + * + * Why wrap the Guzzle HTTP client? Because we want to keep the actual test + * code as simple as possible, and hence not require them to specify the + * 'http_errors = FALSE' request option, nor do we want them to have to + * convert Drupal Url objects to strings. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function request($method, Url $url, array $request_options) { + $request_options[RequestOptions::HTTP_ERRORS] = FALSE; + return $this->httpClient->request($method, $url->toString(), $request_options); + } + + /** + * Asserts that a resource response has the given status code and body. + * + * (Also asserts that the expected error MIME type is present, but this is + * defined globally for the test via static::$expectedErrorMimeType, because + * all error responses should use the same MIME type.) + * + * @param int $expected_status_code + * The expected response status. + * @param string|false $expected_body + * The expected response body. FALSE in case this should not be asserted. + * @param \Psr\Http\Message\ResponseInterface $response + * The response to assert. + */ + protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response) { + $this->assertSame($expected_status_code, $response->getStatusCode()); + if ($expected_status_code < 400) { + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + } + else { + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + } + if ($expected_body !== FALSE) { + $this->assertSame($expected_body, (string) $response->getBody()); + } + } + + /** + * Asserts that a resource error response has the given message. + * + * (Also asserts that the expected error MIME type is present, but this is + * defined globally for the test via static::$expectedErrorMimeType, because + * all error responses should use the same MIME type.) + * + * @param int $expected_status_code + * The expected response status. + * @param string $expected_message + * The expected error message. + * @param \Psr\Http\Message\ResponseInterface $response + * The error response to assert. + */ + protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response) { + // @todo Fix this in https://www.drupal.org/node/2813755. + $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT]; + $expected_body = $this->serializer->encode(['message' => $expected_message], static::$format, $encode_options); + $this->assertResourceResponse($expected_status_code, $expected_body, $response); + } + +}