3 namespace Drupal\Tests\rest\Functional\EntityResource;
5 use Drupal\Component\Utility\NestedArray;
6 use Drupal\Component\Utility\Random;
7 use Drupal\Core\Cache\Cache;
8 use Drupal\Core\Cache\CacheableResponseInterface;
9 use Drupal\Core\Cache\CacheableMetadata;
10 use Drupal\Core\Config\Entity\ConfigEntityInterface;
11 use Drupal\Core\Entity\ContentEntityNullStorage;
12 use Drupal\Core\Entity\EntityInterface;
13 use Drupal\Core\Entity\FieldableEntityInterface;
14 use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
15 use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
17 use Drupal\field\Entity\FieldConfig;
18 use Drupal\field\Entity\FieldStorageConfig;
19 use Drupal\path\Plugin\Field\FieldType\PathItem;
20 use Drupal\rest\ResourceResponseInterface;
21 use Drupal\Tests\rest\Functional\ResourceTestBase;
22 use GuzzleHttp\RequestOptions;
23 use Psr\Http\Message\ResponseInterface;
26 * Even though there is the generic EntityResource, it's necessary for every
27 * entity type to have its own test, because they each have different fields,
28 * validation constraints, et cetera. It's not because the generic case works,
29 * that every case works.
31 * Furthermore, it's necessary to test every format separately, because there
32 * can be entity type-specific normalization or serialization problems.
34 * Subclass this for every entity type. Also respect instructions in
35 * \Drupal\rest\Tests\ResourceTestBase.
37 * For example, for the node test coverage, there is the (abstract)
38 * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which
39 * is then again subclassed for every authentication provider:
40 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest
41 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
42 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest
43 * But the HAL module also adds a new format ('hal_json'), so that format also
44 * needs test coverage (for its own peculiarities in normalization & encoding):
45 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest
46 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest
47 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest
49 * In other words: for every entity type there should be:
50 * 1. an abstract subclass that includes the entity type-specific authorization
51 * (permissions or perhaps custom access control handling, such as node
53 * 2. a concrete subclass extending the abstract entity type-specific subclass
54 * that specifies the exact @code $format @endcode, @code $mimeType @endcode
55 * and @code $auth @endcode for this concrete test. Usually that's all that's
56 * necessary: most concrete subclasses will be very thin.
58 * For every of these concrete subclasses, a comprehensive test scenario will
59 * run per HTTP method:
65 * If there is an entity type-specific edge case scenario to test, then add that
66 * to the entity type-specific abstract subclass. Example:
67 * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields
69 * If there is an entity type-specific format-specific edge case to test, then
70 * add that to a concrete subclass. Example:
71 * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames
73 abstract class EntityResourceTestBase extends ResourceTestBase {
76 * The tested entity type.
80 protected static $entityTypeId = NULL;
83 * The fields that are protected against modification during PATCH requests.
87 protected static $patchProtectedFieldNames;
90 * The fields that need a different (random) value for each new entity created
95 protected static $uniqueFieldNames = [];
98 * Optionally specify which field is the 'label' field. Some entities specify
99 * a 'label_callback', but not a 'label' entity key. For example: User.
101 * @see ::getInvalidNormalizedEntityToCreate
105 protected static $labelFieldName = NULL;
108 * The entity ID for the first created entity in testPost().
110 * The default value of 2 should work for most content entities.
116 protected static $firstCreatedEntityId = 2;
119 * The entity ID for the second created entity in testPost().
121 * The default value of 3 should work for most content entities.
127 protected static $secondCreatedEntityId = 3;
130 * The main entity used for testing.
132 * @var \Drupal\Core\Entity\EntityInterface
137 * Another entity of the same type used for testing.
139 * @var \Drupal\Core\Entity\EntityInterface
141 protected $anotherEntity;
144 * The entity storage.
146 * @var \Drupal\Core\Entity\EntityStorageInterface
148 protected $entityStorage;
151 * Modules to install.
155 public static $modules = ['rest_test', 'text'];
158 * Provides an entity resource.
160 protected function provisionEntityResource() {
161 // It's possible to not have any authentication providers enabled, when
162 // testing public (anonymous) usage of a REST resource.
163 $auth = isset(static::$auth) ? [static::$auth] : [];
164 $this->provisionResource([static::$format], $auth);
170 public function setUp() {
173 // Calculate REST Resource config entity ID.
174 static::$resourceConfigId = 'entity.' . static::$entityTypeId;
176 $this->entityStorage = $this->container->get('entity_type.manager')
177 ->getStorage(static::$entityTypeId);
180 $this->entity = $this->createEntity();
182 if ($this->entity instanceof FieldableEntityInterface) {
183 // Add access-protected field.
184 FieldStorageConfig::create([
185 'entity_type' => static::$entityTypeId,
186 'field_name' => 'field_rest_test',
191 FieldConfig::create([
192 'entity_type' => static::$entityTypeId,
193 'field_name' => 'field_rest_test',
194 'bundle' => $this->entity->bundle(),
196 ->setLabel('Test field')
197 ->setTranslatable(FALSE)
200 // Add multi-value field.
201 FieldStorageConfig::create([
202 'entity_type' => static::$entityTypeId,
203 'field_name' => 'field_rest_test_multivalue',
208 FieldConfig::create([
209 'entity_type' => static::$entityTypeId,
210 'field_name' => 'field_rest_test_multivalue',
211 'bundle' => $this->entity->bundle(),
213 ->setLabel('Test field: multi-value')
214 ->setTranslatable(FALSE)
217 // Reload entity so that it has the new field.
218 $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
219 // Some entity types are not stored, hence they cannot be reloaded.
220 if ($reloaded_entity !== NULL) {
221 $this->entity = $reloaded_entity;
223 // Set a default value on the fields.
224 $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
225 $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
226 $this->entity->save();
232 * Creates the entity to be tested.
234 * @return \Drupal\Core\Entity\EntityInterface
235 * The entity to be tested.
237 abstract protected function createEntity();
240 * Creates another entity to be tested.
242 * @return \Drupal\Core\Entity\EntityInterface
243 * Another entity based on $this->entity.
245 protected function createAnotherEntity() {
246 $entity = $this->entity->createDuplicate();
247 $label_key = $entity->getEntityType()->getKey('label');
249 $entity->set($label_key, $entity->label() . '_dupe');
256 * Returns the expected normalization of the entity.
258 * @see ::createEntity()
262 abstract protected function getExpectedNormalizedEntity();
265 * Returns the normalized POST entity.
271 abstract protected function getNormalizedPostEntity();
274 * Returns the normalized PATCH entity.
276 * By default, reuses ::getNormalizedPostEntity(), which works fine for most
277 * entity types. A counterexample: the 'comment' entity type.
283 protected function getNormalizedPatchEntity() {
284 return $this->getNormalizedPostEntity();
288 * Gets the second normalized POST entity.
290 * Entity types can have non-sequential IDs, and in that case the second
291 * entity created for POST testing needs to be able to specify a different ID.
294 * @see ::getNormalizedPostEntity
297 * An array structure as returned by ::getNormalizedPostEntity().
299 protected function getSecondNormalizedPostEntity() {
300 // Return the values of the "parent" method by default.
301 return $this->getNormalizedPostEntity();
305 * Gets the normalized POST entity with random values for its unique fields.
308 * @see ::getNormalizedPostEntity
311 * An array structure as returned by ::getNormalizedPostEntity().
313 protected function getModifiedEntityForPostTesting() {
314 $normalized_entity = $this->getNormalizedPostEntity();
316 // Ensure that all the unique fields of the entity type get a new random
318 foreach (static::$uniqueFieldNames as $field_name) {
319 $field_definition = $this->entity->getFieldDefinition($field_name);
320 $field_type_class = $field_definition->getItemDefinition()->getClass();
321 $normalized_entity[$field_name] = $field_type_class::generateSampleValue($field_definition);
324 return $normalized_entity;
330 protected function getExpectedUnauthorizedAccessMessage($method) {
332 if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
333 return parent::getExpectedUnauthorizedAccessMessage($method);
336 $permission = $this->entity->getEntityType()->getAdminPermission();
337 if ($permission !== FALSE) {
338 return "The '{$permission}' permission is required.";
341 $http_method_to_entity_operation = [
345 'DELETE' => 'delete',
347 $operation = $http_method_to_entity_operation[$method];
348 $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
350 if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
351 $message .= ' of bundle ' . $this->entity->bundle();
360 protected function getExpectedUnauthorizedAccessCacheability() {
361 return (new CacheableMetadata())
362 ->setCacheTags(static::$auth
363 ? ['4xx-response', 'http_response']
364 : ['4xx-response', 'config:user.role.anonymous', 'http_response'])
365 ->setCacheContexts(['user.permissions']);
369 * The expected cache tags for the GET/HEAD response of the test entity.
375 protected function getExpectedCacheTags() {
376 $expected_cache_tags = [
377 'config:rest.resource.entity.' . static::$entityTypeId,
378 // Necessary for 'bc_entity_resource_permissions'.
379 // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
380 'config:rest.settings',
382 if (!static::$auth) {
383 $expected_cache_tags[] = 'config:user.role.anonymous';
385 $expected_cache_tags[] = 'http_response';
386 return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
390 * The expected cache contexts for the GET/HEAD response of the test entity.
396 protected function getExpectedCacheContexts() {
404 * Test a GET request for an entity, plus edge cases to ensure good DX.
406 public function testGet() {
407 $this->initAuthentication();
408 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
410 // The URL and Guzzle request options that will be used in this test. The
411 // request options will be modified/expanded throughout this test:
412 // - to first test all mistakes a developer might make, and assert that the
413 // error responses provide a good DX
414 // - to eventually result in a well-formed request that succeeds.
415 $url = $this->getEntityResourceUrl();
416 $request_options = [];
418 // DX: 404 when resource not provisioned, 403 if canonical route. HTML
419 // response because missing ?_format query string.
420 $response = $this->request('GET', $url, $request_options);
421 $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
422 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
424 $url->setOption('query', ['_format' => static::$format]);
426 // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
427 // response because ?_format query string is present.
428 $response = $this->request('GET', $url, $request_options);
429 if ($has_canonical_url) {
430 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
433 $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
436 $this->provisionEntityResource();
437 // Simulate the developer again forgetting the ?_format query string.
438 $url->setOption('query', []);
440 // DX: 406 when ?_format is missing, except when requesting a canonical HTML
442 $response = $this->request('GET', $url, $request_options);
443 if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
444 $this->assertSame(403, $response->getStatusCode());
447 $this->assert406Response($response);
450 $url->setOption('query', ['_format' => static::$format]);
452 // DX: forgetting authentication: authentication provider-specific error
455 $response = $this->request('GET', $url, $request_options);
456 $this->assertResponseWhenMissingAuthentication('GET', $response);
459 $request_options[RequestOptions::HEADERS]['REST-test-auth'] = '1';
461 // DX: 403 when attempting to use unallowed authentication provider.
462 $response = $this->request('GET', $url, $request_options);
463 $this->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
465 unset($request_options[RequestOptions::HEADERS]['REST-test-auth']);
466 $request_options[RequestOptions::HEADERS]['REST-test-auth-global'] = '1';
468 // DX: 403 when attempting to use unallowed global authentication provider.
469 $response = $this->request('GET', $url, $request_options);
470 $this->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
472 unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']);
473 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
475 // DX: 403 when unauthorized.
476 $response = $this->request('GET', $url, $request_options);
477 $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
478 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
479 $this->assertArrayNotHasKey('Link', $response->getHeaders());
481 $this->setUpAuthorization('GET');
483 // 200 for well-formed HEAD request.
484 $response = $this->request('HEAD', $url, $request_options);
485 $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
486 $head_headers = $response->getHeaders();
488 // 200 for well-formed GET request. Page Cache hit because of HEAD request.
489 // Same for Dynamic Page Cache hit.
490 $response = $this->request('GET', $url, $request_options);
491 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', static::$auth ? 'HIT' : 'MISS');
492 // Assert that Dynamic Page Cache did not store a ResourceResponse object,
493 // which needs serialization after every cache hit. Instead, it should
494 // contain a flattened response. Otherwise performance suffers.
495 // @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
496 $cache_items = $this->container->get('database')
497 ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
498 ':pattern' => '%[route]=rest.%',
500 ->fetchAllAssoc('cid');
501 $this->assertTrue(count($cache_items) >= 2);
502 $found_cache_redirect = FALSE;
503 $found_cached_200_response = FALSE;
504 $other_cached_responses_are_4xx = TRUE;
505 foreach ($cache_items as $cid => $cache_item) {
506 $cached_data = unserialize($cache_item->data);
507 if (!isset($cached_data['#cache_redirect'])) {
508 $cached_response = $cached_data['#response'];
509 if ($cached_response->getStatusCode() === 200) {
510 $found_cached_200_response = TRUE;
512 elseif (!$cached_response->isClientError()) {
513 $other_cached_responses_are_4xx = FALSE;
515 $this->assertNotInstanceOf(ResourceResponseInterface::class, $cached_response);
516 $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
519 $found_cache_redirect = TRUE;
522 $this->assertTrue($found_cache_redirect);
523 $this->assertTrue($found_cached_200_response);
524 $this->assertTrue($other_cached_responses_are_4xx);
526 // Sort the serialization data first so we can do an identical comparison
527 // for the keys with the array order the same (it needs to match with
528 // identical comparison).
529 $expected = $this->getExpectedNormalizedEntity();
530 static::recursiveKSort($expected);
531 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
532 static::recursiveKSort($actual);
533 $this->assertSame($expected, $actual);
535 // Not only assert the normalization, also assert deserialization of the
536 // response results in the expected object.
537 // Note: deserialization of the XML format is not supported, so only test
538 // this for other formats.
539 if (static::$format !== 'xml') {
540 // @todo Work-around for HAL's FileEntityNormalizer::denormalize() being
541 // broken, being fixed in https://www.drupal.org/node/1927648, where this
542 // if-test should be removed.
543 if (!(static::$entityTypeId === 'file' && static::$format === 'hal_json')) {
544 $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
545 $this->assertSame($unserialized->uuid(), $this->entity->uuid());
548 // Finally, assert that the expected 'Link' headers are present.
549 if ($this->entity->getEntityType()->getLinkTemplates()) {
550 $this->assertArrayHasKey('Link', $response->getHeaders());
551 $link_relation_type_manager = $this->container->get('plugin.manager.link_relation_type');
552 $expected_link_relation_headers = array_map(function ($relation_name) use ($link_relation_type_manager) {
553 $link_relation_type = $link_relation_type_manager->createInstance($relation_name);
554 return $link_relation_type->isRegistered()
555 ? $link_relation_type->getRegisteredName()
556 : $link_relation_type->getExtensionUri();
557 }, array_keys($this->entity->getEntityType()->getLinkTemplates()));
558 $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
560 if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
565 $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
567 $get_headers = $response->getHeaders();
569 // Verify that the GET and HEAD responses are the same. The only difference
570 // is that there's no body. For this reason the 'Transfer-Encoding' and
571 // 'Vary' headers are also added to the list of headers to ignore, as they
572 // may be added to GET requests, depending on web server configuration. They
573 // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
574 $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary'];
575 $header_cleaner = function ($headers) use ($ignored_headers) {
576 foreach ($headers as $header => $value) {
577 if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
578 unset($headers[$header]);
583 $get_headers = $header_cleaner($get_headers);
584 $head_headers = $header_cleaner($head_headers);
585 $this->assertSame($get_headers, $head_headers);
587 // BC: serialization_update_8302().
588 // Only run this for fieldable entities. It doesn't make sense for config
589 // entities as config values are already casted. They also run through the
590 // ConfigEntityNormalizer, which doesn't deal with fields individually.
591 if ($this->entity instanceof FieldableEntityInterface) {
592 // Test primitive data casting BC (strings).
593 $this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
594 // Rebuild the container so new config is reflected in the addition of the
595 // PrimitiveDataNormalizer.
598 $response = $this->request('GET', $url, $request_options);
599 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
601 // Again do an identical comparison, but this time transform the expected
602 // normalized entity's values to strings. This ensures the BC layer for
603 // bc_primitives_as_strings works as expected.
604 $expected = $this->getExpectedNormalizedEntity();
605 // Config entities are not affected.
606 // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
607 $expected = static::castToString($expected);
608 static::recursiveKSort($expected);
609 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
610 static::recursiveKSort($actual);
611 $this->assertSame($expected, $actual);
613 // Reset the config value and rebuild.
614 $this->config('serialization.settings')->set('bc_primitives_as_strings', FALSE)->save(TRUE);
618 // BC: serialization_update_8401().
619 // Only run this for fieldable entities. It doesn't make sense for config
620 // entities as config values always use the raw values (as per the config
621 // schema), returned directly from the ConfigEntityNormalizer, which
622 // doesn't deal with fields individually.
623 if ($this->entity instanceof FieldableEntityInterface) {
624 // Test the BC settings for timestamp values.
625 $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
626 // Rebuild the container so new config is reflected in the addition of the
627 // TimestampItemNormalizer.
630 $response = $this->request('GET', $url, $request_options);
631 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
633 // This ensures the BC layer for bc_timestamp_normalizer_unix works as
634 // expected. This method should be using
635 // ::formatExpectedTimestampValue() to generate the timestamp value. This
636 // will take into account the above config setting.
637 $expected = $this->getExpectedNormalizedEntity();
638 // Config entities are not affected.
639 // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
640 static::recursiveKSort($expected);
641 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
642 static::recursiveKSort($actual);
643 $this->assertSame($expected, $actual);
645 // Reset the config value and rebuild.
646 $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
650 // BC: rest_update_8203().
651 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
652 $this->refreshTestStateAfterRestConfigChange();
654 // DX: 403 when unauthorized.
655 $response = $this->request('GET', $url, $request_options);
656 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
658 $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
660 // 200 for well-formed request.
661 $response = $this->request('GET', $url, $request_options);
662 $expected_cache_tags = $this->getExpectedCacheTags();
663 $expected_cache_contexts = $this->getExpectedCacheContexts();
664 // @todo Fix BlockAccessControlHandler::mergeCacheabilityFromConditions() in
665 // https://www.drupal.org/node/2867881
666 if (static::$entityTypeId === 'block') {
667 $expected_cache_contexts = Cache::mergeContexts($expected_cache_contexts, ['user.permissions']);
669 // \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies to
670 // cacheable anonymous responses: it updates their cacheability. Therefore
671 // we must update our cacheability expectations for anonymous responses
673 if (!static::$auth && in_array('user.permissions', $expected_cache_contexts, TRUE)) {
674 $expected_cache_tags = Cache::mergeTags($expected_cache_tags, ['config:user.role.anonymous']);
676 $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
678 $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
679 $this->refreshTestStateAfterRestConfigChange();
681 // DX: upon disabling a resource, it's immediately no longer available.
682 $this->assertResourceNotAvailable($url, $request_options);
684 $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
685 $this->refreshTestStateAfterRestConfigChange();
687 // DX: upon re-enabling a resource, immediate 200.
688 $response = $this->request('GET', $url, $request_options);
689 $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
691 $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
692 $this->refreshTestStateAfterRestConfigChange();
694 // DX: upon deleting a resource, it's immediately no longer available.
695 $this->assertResourceNotAvailable($url, $request_options);
697 $this->provisionEntityResource();
698 $url->setOption('query', ['_format' => 'non_existing_format']);
700 // DX: 406 when requesting unsupported format.
701 $response = $this->request('GET', $url, $request_options);
702 $this->assert406Response($response);
703 $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
705 $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
707 // DX: 406 when requesting unsupported format but specifying Accept header:
708 // should result in a text/plain response.
709 $response = $this->request('GET', $url, $request_options);
710 $this->assert406Response($response);
711 $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
713 $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET');
714 $url->setRouteParameter(static::$entityTypeId, 987654321);
715 $url->setOption('query', ['_format' => static::$format]);
717 // DX: 404 when GETting non-existing entity.
718 $response = $this->request('GET', $url, $request_options);
719 $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
720 $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET")';
721 $this->assertResourceErrorResponse(404, $message, $response);
725 * Transforms a normalization: casts all non-string types to strings.
727 * @param array $normalization
728 * A normalization to transform.
731 * The transformed normalization.
733 protected static function castToString(array $normalization) {
734 foreach ($normalization as $key => $value) {
735 if (is_bool($value)) {
736 $normalization[$key] = (string) (int) $value;
738 elseif (is_int($value) || is_float($value)) {
739 $normalization[$key] = (string) $value;
741 elseif (is_array($value)) {
742 $normalization[$key] = static::castToString($value);
745 return $normalization;
749 * Recursively sorts an array by key.
751 * @param array $array
757 protected static function recursiveKSort(array &$array) {
758 // First, sort the main array.
761 // Then check for child arrays.
762 foreach ($array as $key => &$value) {
763 if (is_array($value)) {
764 static::recursiveKSort($value);
770 * Tests a POST request for an entity, plus edge cases to ensure good DX.
772 public function testPost() {
773 // @todo Remove this in https://www.drupal.org/node/2300677.
774 if ($this->entity instanceof ConfigEntityInterface) {
775 $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
779 $this->initAuthentication();
780 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
782 // Try with all of the following request bodies.
783 $unparseable_request_body = '!{>}<';
784 $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
785 $parseable_valid_request_body_2 = $this->serializer->encode($this->getSecondNormalizedPostEntity(), static::$format);
786 $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity(), 'label'), static::$format);
787 $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format);
788 $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
790 // The URL and Guzzle request options that will be used in this test. The
791 // request options will be modified/expanded throughout this test:
792 // - to first test all mistakes a developer might make, and assert that the
793 // error responses provide a good DX
794 // - to eventually result in a well-formed request that succeeds.
795 $url = $this->getEntityResourcePostUrl();
796 $request_options = [];
798 // DX: 404 when resource not provisioned. HTML response because missing
799 // ?_format query string.
800 $response = $this->request('POST', $url, $request_options);
801 $this->assertSame(404, $response->getStatusCode());
802 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
804 $url->setOption('query', ['_format' => static::$format]);
806 // DX: 404 when resource not provisioned.
807 $response = $this->request('POST', $url, $request_options);
808 $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getEntityResourcePostUrl()->setAbsolute()->toString()) . '"', $response);
810 $this->provisionEntityResource();
811 // Simulate the developer again forgetting the ?_format query string.
812 $url->setOption('query', []);
814 // DX: 415 when no Content-Type request header. HTML response because
815 // missing ?_format query string.
816 $response = $this->request('POST', $url, $request_options);
817 $this->assertSame(415, $response->getStatusCode());
818 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
819 $this->assertContains('A client error happened', (string) $response->getBody());
821 $url->setOption('query', ['_format' => static::$format]);
823 // DX: 415 when no Content-Type request header.
824 $response = $this->request('POST', $url, $request_options);
825 $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
827 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
829 // DX: 400 when no request body.
830 $response = $this->request('POST', $url, $request_options);
831 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
833 $request_options[RequestOptions::BODY] = $unparseable_request_body;
835 // DX: 400 when unparseable request body.
836 $response = $this->request('POST', $url, $request_options);
837 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
839 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
842 // DX: forgetting authentication: authentication provider-specific error
844 $response = $this->request('POST', $url, $request_options);
845 $this->assertResponseWhenMissingAuthentication('POST', $response);
848 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
850 // DX: 403 when unauthorized.
851 $response = $this->request('POST', $url, $request_options);
852 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
854 $this->setUpAuthorization('POST');
856 // DX: 422 when invalid entity: multiple values sent for single-value field.
857 $response = $this->request('POST', $url, $request_options);
858 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
859 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
860 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
862 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
864 // DX: 422 when invalid entity: UUID field too long.
865 // @todo Fix this in https://www.drupal.org/node/2149851.
866 if ($this->entity->getEntityType()->hasKey('uuid')) {
867 $response = $this->request('POST', $url, $request_options);
868 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
871 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
873 // DX: 403 when entity contains field without 'edit' access.
874 $response = $this->request('POST', $url, $request_options);
875 $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
877 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
879 // Before sending a well-formed request, allow the normalization and
880 // authentication provider edge cases to also be tested.
881 $this->assertNormalizationEdgeCases('POST', $url, $request_options);
882 $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
884 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
886 // DX: 415 when request body in existing but not allowed format.
887 $response = $this->request('POST', $url, $request_options);
888 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
890 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
892 // 201 for well-formed request.
893 $response = $this->request('POST', $url, $request_options);
894 $this->assertResourceResponse(201, FALSE, $response);
895 if ($has_canonical_url) {
896 $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
897 $this->assertSame([$location], $response->getHeader('Location'));
900 $this->assertSame([], $response->getHeader('Location'));
902 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
903 // If the entity is stored, perform extra checks.
904 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
905 // Assert that the entity was indeed created, and that the response body
906 // contains the serialized created entity.
907 $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
908 $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
909 // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
910 // its body unconditionally.
911 if (static::$entityTypeId !== 'taxonomy_term') {
912 $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
914 // Assert that the entity was indeed created using the POSTed values.
915 foreach ($this->getNormalizedPostEntity() as $field_name => $field_normalization) {
916 // Some top-level keys in the normalization may not be fields on the
917 // entity (for example '_links' and '_embedded' in the HAL normalization).
918 if ($created_entity->hasField($field_name)) {
919 // Subset, not same, because we can e.g. send just the target_id for the
920 // bundle in a POST request; the response will include more properties.
921 $this->assertArraySubset(static::castToString($field_normalization), $created_entity->get($field_name)
927 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
928 $this->refreshTestStateAfterRestConfigChange();
929 $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
931 // DX: 403 when unauthorized.
932 $response = $this->request('POST', $url, $request_options);
933 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
935 $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
937 // 201 for well-formed request.
938 // If the entity is stored, delete the first created entity (in case there
939 // is a uniqueness constraint).
940 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
941 $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
943 $response = $this->request('POST', $url, $request_options);
944 $this->assertResourceResponse(201, FALSE, $response);
945 $created_entity = $this->entityStorage->load(static::$secondCreatedEntityId);
946 if ($has_canonical_url) {
947 $location = $created_entity->toUrl('canonical')->setAbsolute(TRUE)->toString();
948 $this->assertSame([$location], $response->getHeader('Location'));
951 $this->assertSame([], $response->getHeader('Location'));
953 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
955 if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
956 // 500 when creating an entity with a duplicate UUID.
957 $normalized_entity = $this->getModifiedEntityForPostTesting();
958 $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $created_entity->uuid()]];
959 $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
960 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
962 $response = $this->request('POST', $url, $request_options);
963 $this->assertSame(500, $response->getStatusCode());
964 $this->assertContains('Internal Server Error', (string) $response->getBody());
966 // 201 when successfully creating an entity with a new UUID.
967 $normalized_entity = $this->getModifiedEntityForPostTesting();
968 $new_uuid = \Drupal::service('uuid')->generate();
969 $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $new_uuid]];
970 $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
971 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
973 $response = $this->request('POST', $url, $request_options);
974 $this->assertResourceResponse(201, FALSE, $response);
975 $entities = $this->entityStorage->loadByProperties([$created_entity->getEntityType()->getKey('uuid') => $new_uuid]);
976 $new_entity = reset($entities);
977 $this->assertNotNull($new_entity);
978 $new_entity->delete();
981 // BC: old default POST URLs have their path updated by the inbound path
982 // processor \Drupal\rest\PathProcessor\PathProcessorEntityResourceBC to the
983 // new URL, which is derived from the 'create' link template if an entity
984 // type specifies it.
985 if ($this->entity->getEntityType()->hasLinkTemplate('create')) {
986 $this->entityStorage->load(static::$secondCreatedEntityId)->delete();
987 $old_url = Url::fromUri('base:entity/' . static::$entityTypeId);
988 $old_url->setOption('query', ['_format' => static::$format]);
989 $response = $this->request('POST', $old_url, $request_options);
990 $this->assertResourceResponse(201, FALSE, $response);
995 * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
997 public function testPatch() {
998 // @todo Remove this in https://www.drupal.org/node/2300677.
999 if ($this->entity instanceof ConfigEntityInterface) {
1000 $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
1004 // Patch testing requires that another entity of the same type exists.
1005 $this->anotherEntity = $this->createAnotherEntity();
1007 $this->initAuthentication();
1008 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1010 // Try with all of the following request bodies.
1011 $unparseable_request_body = '!{>}<';
1012 $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1013 $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1014 $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'label'), static::$format);
1015 $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
1016 // The 'field_rest_test' field does not allow 'view' access, so does not end
1017 // up in the normalization. Even when we explicitly add it the normalization
1018 // that we send in the body of a PATCH request, it is considered invalid.
1019 $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], static::$format);
1021 // The URL and Guzzle request options that will be used in this test. The
1022 // request options will be modified/expanded throughout this test:
1023 // - to first test all mistakes a developer might make, and assert that the
1024 // error responses provide a good DX
1025 // - to eventually result in a well-formed request that succeeds.
1026 $url = $this->getEntityResourceUrl();
1027 $request_options = [];
1029 // DX: 404 when resource not provisioned, 405 if canonical route. Plain text
1030 // or HTML response because missing ?_format query string.
1031 $response = $this->request('PATCH', $url, $request_options);
1032 if ($has_canonical_url) {
1033 $this->assertSame(405, $response->getStatusCode());
1034 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1035 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1036 $this->assertContains('A client error happened', (string) $response->getBody());
1039 $this->assertSame(404, $response->getStatusCode());
1040 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1043 $url->setOption('query', ['_format' => static::$format]);
1045 // DX: 404 when resource not provisioned, 405 if canonical route.
1046 $response = $this->request('PATCH', $url, $request_options);
1047 if ($has_canonical_url) {
1048 $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1051 $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1054 $this->provisionEntityResource();
1055 // Simulate the developer again forgetting the ?_format query string.
1056 $url->setOption('query', []);
1058 // DX: 415 when no Content-Type request header.
1059 $response = $this->request('PATCH', $url, $request_options);
1060 $this->assertSame(415, $response->getStatusCode());
1061 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1062 $this->assertContains('A client error happened', (string) $response->getBody());
1064 $url->setOption('query', ['_format' => static::$format]);
1066 // DX: 415 when no Content-Type request header.
1067 $response = $this->request('PATCH', $url, $request_options);
1068 $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
1070 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1072 // DX: 400 when no request body.
1073 $response = $this->request('PATCH', $url, $request_options);
1074 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
1076 $request_options[RequestOptions::BODY] = $unparseable_request_body;
1078 // DX: 400 when unparseable request body.
1079 $response = $this->request('PATCH', $url, $request_options);
1080 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
1082 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
1084 if (static::$auth) {
1085 // DX: forgetting authentication: authentication provider-specific error
1087 $response = $this->request('PATCH', $url, $request_options);
1088 $this->assertResponseWhenMissingAuthentication('PATCH', $response);
1091 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1093 // DX: 403 when unauthorized.
1094 $response = $this->request('PATCH', $url, $request_options);
1095 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1097 $this->setUpAuthorization('PATCH');
1099 // DX: 422 when invalid entity: multiple values sent for single-value field.
1100 $response = $this->request('PATCH', $url, $request_options);
1101 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
1102 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
1103 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
1105 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
1107 // DX: 403 when entity contains field without 'edit' access.
1108 $response = $this->request('PATCH', $url, $request_options);
1109 $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1111 // DX: 403 when entity trying to update an entity's ID field.
1112 $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'id'), static::$format);;
1113 $response = $this->request('PATCH', $url, $request_options);
1114 $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'.", $response);
1116 if ($this->entity->getEntityType()->hasKey('uuid')) {
1117 // DX: 403 when entity trying to update an entity's UUID field.
1118 $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'uuid'), static::$format);;
1119 $response = $this->request('PATCH', $url, $request_options);
1120 $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'.", $response);
1123 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
1125 // DX: 403 when entity contains field without 'edit' nor 'view' access, even
1126 // when the value for that field matches the current value. This is allowed
1127 // in principle, but leads to information disclosure.
1128 $response = $this->request('PATCH', $url, $request_options);
1129 $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1131 // DX: 403 when sending PATCH request with updated read-only fields.
1132 list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
1133 // Send PATCH request by serializing the modified entity, assert the error
1134 // response, change the modified entity field that caused the error response
1135 // back to its original value, repeat.
1136 for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
1137 $patch_protected_field_name = static::$patchProtectedFieldNames[$i];
1138 $request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
1139 $response = $this->request('PATCH', $url, $request_options);
1140 $this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'.", $response);
1141 $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
1144 // 200 for well-formed PATCH request that sends all fields (even including
1145 // read-only ones, but with unchanged values).
1146 $valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
1147 $request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
1148 $response = $this->request('PATCH', $url, $request_options);
1149 $this->assertResourceResponse(200, FALSE, $response);
1151 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
1153 // Before sending a well-formed request, allow the normalization and
1154 // authentication provider edge cases to also be tested.
1155 $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
1156 $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
1158 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
1160 // DX: 415 when request body in existing but not allowed format.
1161 $response = $this->request('PATCH', $url, $request_options);
1162 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
1164 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1166 // 200 for well-formed request.
1167 $response = $this->request('PATCH', $url, $request_options);
1168 $this->assertResourceResponse(200, FALSE, $response);
1169 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1170 // Assert that the entity was indeed updated, and that the response body
1171 // contains the serialized updated entity.
1172 $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
1173 $updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]);
1174 $this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
1175 // Assert that the entity was indeed created using the PATCHed values.
1176 foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) {
1177 // Some top-level keys in the normalization may not be fields on the
1178 // entity (for example '_links' and '_embedded' in the HAL normalization).
1179 if ($updated_entity->hasField($field_name)) {
1180 // Subset, not same, because we can e.g. send just the target_id for the
1181 // bundle in a PATCH request; the response will include more properties.
1182 $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
1185 // Ensure that fields do not get deleted if they're not present in the PATCH
1186 // request. Test this using the configurable field that we added, but which
1187 // is not sent in the PATCH request.
1188 $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
1190 // Multi-value field: remove item 0. Then item 1 becomes item 0.
1191 $normalization_multi_value_tests = $this->getNormalizedPatchEntity();
1192 $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
1193 $normalization_remove_item = $normalization_multi_value_tests;
1194 unset($normalization_remove_item['field_rest_test_multivalue'][0]);
1195 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format);
1196 $response = $this->request('PATCH', $url, $request_options);
1197 $this->assertResourceResponse(200, FALSE, $response);
1198 $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1200 // Multi-value field: add one item before the existing one, and one after.
1201 $normalization_add_items = $normalization_multi_value_tests;
1202 $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three'];
1203 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format);
1204 $response = $this->request('PATCH', $url, $request_options);
1205 $this->assertResourceResponse(200, FALSE, $response);
1206 $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1208 // BC: rest_update_8203().
1209 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1210 $this->refreshTestStateAfterRestConfigChange();
1211 $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
1213 // DX: 403 when unauthorized.
1214 $response = $this->request('PATCH', $url, $request_options);
1215 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1217 $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
1219 // 200 for well-formed request.
1220 $response = $this->request('PATCH', $url, $request_options);
1221 $this->assertResourceResponse(200, FALSE, $response);
1222 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1226 * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
1228 public function testDelete() {
1229 // @todo Remove this in https://www.drupal.org/node/2300677.
1230 if ($this->entity instanceof ConfigEntityInterface) {
1231 $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
1235 $this->initAuthentication();
1236 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1238 // The URL and Guzzle request options that will be used in this test. The
1239 // request options will be modified/expanded throughout this test:
1240 // - to first test all mistakes a developer might make, and assert that the
1241 // error responses provide a good DX
1242 // - to eventually result in a well-formed request that succeeds.
1243 $url = $this->getEntityResourceUrl();
1244 $request_options = [];
1246 // DX: 404 when resource not provisioned, but 405 if canonical route. Plain
1247 // text or HTML response because missing ?_format query string.
1248 $response = $this->request('DELETE', $url, $request_options);
1249 if ($has_canonical_url) {
1250 $this->assertSame(405, $response->getStatusCode());
1251 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1252 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1253 $this->assertContains('A client error happened', (string) $response->getBody());
1256 $this->assertSame(404, $response->getStatusCode());
1257 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1260 $url->setOption('query', ['_format' => static::$format]);
1262 // DX: 404 when resource not provisioned, 405 if canonical route.
1263 $response = $this->request('DELETE', $url, $request_options);
1264 if ($has_canonical_url) {
1265 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1266 $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1269 $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1272 $this->provisionEntityResource();
1274 if (static::$auth) {
1275 // DX: forgetting authentication: authentication provider-specific error
1277 $response = $this->request('DELETE', $url, $request_options);
1278 $this->assertResponseWhenMissingAuthentication('DELETE', $response);
1281 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1283 // DX: 403 when unauthorized.
1284 $response = $this->request('DELETE', $url, $request_options);
1285 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1287 $this->setUpAuthorization('DELETE');
1289 // Before sending a well-formed request, allow the authentication provider's
1290 // edge cases to also be tested.
1291 $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
1293 // 204 for well-formed request.
1294 $response = $this->request('DELETE', $url, $request_options);
1295 $this->assertResourceResponse(204, '', $response);
1297 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1298 $this->refreshTestStateAfterRestConfigChange();
1299 $this->entity = $this->createEntity();
1300 $url = $this->getEntityResourceUrl()->setOption('query', $url->getOption('query'));
1302 // DX: 403 when unauthorized.
1303 $response = $this->request('DELETE', $url, $request_options);
1304 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1306 $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
1308 // 204 for well-formed request.
1309 $response = $this->request('DELETE', $url, $request_options);
1310 $this->assertResourceResponse(204, '', $response);
1316 protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
1317 // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
1318 // types with bundles MUST send their bundle field to be denormalizable.
1319 $entity_type = $this->entity->getEntityType();
1320 if ($entity_type->hasKey('bundle')) {
1321 $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
1322 $normalization = $this->getNormalizedPostEntity();
1324 // The bundle type itself can be validated only if there's a bundle entity
1326 if ($entity_type->getBundleEntityType()) {
1327 $normalization[$bundle_field_name] = 'bad_bundle_name';
1328 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1330 // DX: 422 when incorrect entity type bundle is specified.
1331 $response = $this->request($method, $url, $request_options);
1332 $this->assertResourceErrorResponse(422, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
1335 unset($normalization[$bundle_field_name]);
1336 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1338 // DX: 422 when no entity type bundle is specified.
1339 $response = $this->request($method, $url, $request_options);
1340 $this->assertResourceErrorResponse(422, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response);
1345 * Gets an entity resource's GET/PATCH/DELETE URL.
1347 * @return \Drupal\Core\Url
1348 * The URL to GET/PATCH/DELETE.
1350 protected function getEntityResourceUrl() {
1351 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1352 // Note that the 'canonical' link relation type must be specified explicitly
1353 // in the call to ::toUrl(). 'canonical' is the default for
1354 // \Drupal\Core\Entity\Entity::toUrl(), but ConfigEntityBase overrides this.
1355 return $has_canonical_url ? $this->entity->toUrl('canonical') : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
1359 * Gets an entity resource's POST URL.
1361 * @return \Drupal\Core\Url
1362 * The URL to POST to.
1364 protected function getEntityResourcePostUrl() {
1365 $has_create_url = $this->entity->hasLinkTemplate('create');
1366 return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId);
1370 * Clones the given entity and modifies all PATCH-protected fields.
1372 * @param \Drupal\Core\Entity\EntityInterface $entity
1373 * The entity being tested and to modify.
1376 * Contains two items:
1377 * 1. The modified entity object.
1378 * 2. The original field values, keyed by field name.
1382 protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
1383 $modified_entity = clone $entity;
1384 $original_values = [];
1385 foreach (static::$patchProtectedFieldNames as $field_name) {
1386 $field = $modified_entity->get($field_name);
1387 $original_values[$field_name] = $field->getValue();
1388 switch ($field->getItemDefinition()->getClass()) {
1389 case EntityReferenceItem::class:
1390 // EntityReferenceItem::generateSampleValue() picks one of the last 50
1391 // entities of the supported type & bundle. We don't care if the value
1392 // is valid, we only care that it's different.
1393 $field->setValue(['target_id' => 99999]);
1395 case BooleanItem::class:
1396 // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
1397 // chance of not picking a different value.
1398 $field->value = ((int) $field->value) === 1 ? '0' : '1';
1400 case PathItem::class:
1401 // PathItem::generateSampleValue() doesn't set a PID, which causes
1402 // PathItem::postSave() to fail. Keep the PID (and other properties),
1403 // just modify the alias.
1404 $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
1407 $original_field = clone $field;
1408 while ($field->equals($original_field)) {
1409 $field->generateSampleItems();
1415 return [$modified_entity, $original_values];
1419 * Makes the given entity normalization invalid.
1421 * @param array $normalization
1422 * An entity normalization.
1423 * @param string $entity_key
1424 * The entity key whose normalization to make invalid.
1427 * The updated entity normalization, now invalid.
1429 protected function makeNormalizationInvalid(array $normalization, $entity_key) {
1430 $entity_type = $this->entity->getEntityType();
1431 switch ($entity_key) {
1433 // Add a second label to this entity to make it invalid.
1434 $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
1435 $normalization[$label_field][1]['value'] = 'Second Title';
1438 $normalization[$entity_type->getKey('id')][0]['value'] = $this->anotherEntity->id();
1441 $normalization[$entity_type->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid();
1444 return $normalization;
1448 * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1450 * Asserting a 406 response should be easy, but it's not, due to bugs.
1452 * Drupal returns a 403 response instead of a 406 response when:
1453 * - there is a canonical route, i.e. one that serves HTML
1454 * - unless the user is logged in with any non-global authentication provider,
1455 * because then they tried to access a route that requires the user to be
1456 * authenticated, but they used an authentication provider that is only
1457 * accepted for specific routes, and HTML routes never have such specific
1458 * authentication providers specified. (By default, only 'cookie' is a
1459 * global authentication provider.)
1461 * @todo Remove this in https://www.drupal.org/node/2805279.
1463 * @param \Psr\Http\Message\ResponseInterface $response
1464 * The response to assert.
1466 protected function assert406Response(ResponseInterface $response) {
1467 if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1468 $this->assertSame(403, $response->getStatusCode());
1471 // This is the desired response.
1472 $this->assertSame(406, $response->getStatusCode());
1477 * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1479 * @param \Drupal\Core\Url $url
1481 * @param array $request_options
1482 * Request options to apply.
1484 protected function assertResourceNotAvailable(Url $url, array $request_options) {
1485 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1486 $response = $this->request('GET', $url, $request_options);
1487 if (!$has_canonical_url) {
1488 $this->assertSame(404, $response->getStatusCode());
1491 $this->assert406Response($response);