3 namespace Drupal\Tests\rest\Functional\EntityResource;
5 use Drupal\Component\Assertion\Inspector;
6 use Drupal\Component\Utility\NestedArray;
7 use Drupal\Component\Utility\Random;
8 use Drupal\Core\Cache\Cache;
9 use Drupal\Core\Cache\CacheableResponseInterface;
10 use Drupal\Core\Cache\CacheableMetadata;
11 use Drupal\Core\Config\Entity\ConfigEntityInterface;
12 use Drupal\Core\Entity\ContentEntityNullStorage;
13 use Drupal\Core\Entity\EntityInterface;
14 use Drupal\Core\Entity\FieldableEntityInterface;
15 use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
16 use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
18 use Drupal\field\Entity\FieldConfig;
19 use Drupal\field\Entity\FieldStorageConfig;
20 use Drupal\path\Plugin\Field\FieldType\PathItem;
21 use Drupal\rest\ResourceResponseInterface;
22 use Drupal\Tests\rest\Functional\ResourceTestBase;
23 use GuzzleHttp\RequestOptions;
24 use Psr\Http\Message\ResponseInterface;
27 * Even though there is the generic EntityResource, it's necessary for every
28 * entity type to have its own test, because they each have different fields,
29 * validation constraints, et cetera. It's not because the generic case works,
30 * that every case works.
32 * Furthermore, it's necessary to test every format separately, because there
33 * can be entity type-specific normalization or serialization problems.
35 * Subclass this for every entity type. Also respect instructions in
36 * \Drupal\rest\Tests\ResourceTestBase.
38 * For example, for the node test coverage, there is the (abstract)
39 * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which
40 * is then again subclassed for every authentication provider:
41 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest
42 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
43 * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest
44 * But the HAL module also adds a new format ('hal_json'), so that format also
45 * needs test coverage (for its own peculiarities in normalization & encoding):
46 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest
47 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest
48 * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest
50 * In other words: for every entity type there should be:
51 * 1. an abstract subclass that includes the entity type-specific authorization
52 * (permissions or perhaps custom access control handling, such as node
54 * 2. a concrete subclass extending the abstract entity type-specific subclass
55 * that specifies the exact @code $format @endcode, @code $mimeType @endcode
56 * and @code $auth @endcode for this concrete test. Usually that's all that's
57 * necessary: most concrete subclasses will be very thin.
59 * For every of these concrete subclasses, a comprehensive test scenario will
60 * run per HTTP method:
66 * If there is an entity type-specific edge case scenario to test, then add that
67 * to the entity type-specific abstract subclass. Example:
68 * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields
70 * If there is an entity type-specific format-specific edge case to test, then
71 * add that to a concrete subclass. Example:
72 * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames
74 abstract class EntityResourceTestBase extends ResourceTestBase {
77 * The tested entity type.
81 protected static $entityTypeId = NULL;
84 * The fields that are protected against modification during PATCH requests.
86 * Keys are field names, values are expected access denied reasons.
90 protected static $patchProtectedFieldNames;
93 * The fields that need a different (random) value for each new entity created
98 protected static $uniqueFieldNames = [];
101 * Optionally specify which field is the 'label' field. Some entities specify
102 * a 'label_callback', but not a 'label' entity key. For example: User.
104 * @see ::getInvalidNormalizedEntityToCreate
108 protected static $labelFieldName = NULL;
111 * The entity ID for the first created entity in testPost().
113 * The default value of 2 should work for most content entities.
119 protected static $firstCreatedEntityId = 2;
122 * The entity ID for the second created entity in testPost().
124 * The default value of 3 should work for most content entities.
130 protected static $secondCreatedEntityId = 3;
133 * The main entity used for testing.
135 * @var \Drupal\Core\Entity\EntityInterface
140 * Another entity of the same type used for testing.
142 * @var \Drupal\Core\Entity\EntityInterface
144 protected $anotherEntity;
147 * The entity storage.
149 * @var \Drupal\Core\Entity\EntityStorageInterface
151 protected $entityStorage;
154 * Modules to install.
158 public static $modules = ['rest_test', 'text'];
161 * Provides an entity resource.
163 * @param bool $single_format
164 * Provisions a single-format entity REST resource. Defaults to FALSE.
166 protected function provisionEntityResource($single_format = FALSE) {
167 if ($existing = $this->resourceConfigStorage->load(static::$resourceConfigId)) {
171 $format = $single_format
173 : [static::$format, 'foobar'];
174 // It's possible to not have any authentication providers enabled, when
175 // testing public (anonymous) usage of a REST resource.
176 $auth = isset(static::$auth) ? [static::$auth] : [];
177 $this->provisionResource($format, $auth);
183 public function setUp() {
186 // Calculate REST Resource config entity ID.
187 static::$resourceConfigId = 'entity.' . static::$entityTypeId;
189 $this->entityStorage = $this->container->get('entity_type.manager')
190 ->getStorage(static::$entityTypeId);
193 $this->entity = $this->createEntity();
195 if ($this->entity instanceof FieldableEntityInterface) {
196 // Add access-protected field.
197 FieldStorageConfig::create([
198 'entity_type' => static::$entityTypeId,
199 'field_name' => 'field_rest_test',
204 FieldConfig::create([
205 'entity_type' => static::$entityTypeId,
206 'field_name' => 'field_rest_test',
207 'bundle' => $this->entity->bundle(),
209 ->setLabel('Test field')
210 ->setTranslatable(FALSE)
213 // Add multi-value field.
214 FieldStorageConfig::create([
215 'entity_type' => static::$entityTypeId,
216 'field_name' => 'field_rest_test_multivalue',
221 FieldConfig::create([
222 'entity_type' => static::$entityTypeId,
223 'field_name' => 'field_rest_test_multivalue',
224 'bundle' => $this->entity->bundle(),
226 ->setLabel('Test field: multi-value')
227 ->setTranslatable(FALSE)
230 // Reload entity so that it has the new field.
231 $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
232 // Some entity types are not stored, hence they cannot be reloaded.
233 if ($reloaded_entity !== NULL) {
234 $this->entity = $reloaded_entity;
236 // Set a default value on the fields.
237 $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
238 $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
239 $this->entity->set('rest_test_validation', ['value' => 'allowed value']);
240 $this->entity->save();
246 * Creates the entity to be tested.
248 * @return \Drupal\Core\Entity\EntityInterface
249 * The entity to be tested.
251 abstract protected function createEntity();
254 * Creates another entity to be tested.
256 * @return \Drupal\Core\Entity\EntityInterface
257 * Another entity based on $this->entity.
259 protected function createAnotherEntity() {
260 $entity = $this->entity->createDuplicate();
261 $label_key = $entity->getEntityType()->getKey('label');
263 $entity->set($label_key, $entity->label() . '_dupe');
270 * Returns the expected normalization of the entity.
272 * @see ::createEntity()
276 abstract protected function getExpectedNormalizedEntity();
279 * Returns the normalized POST entity.
285 abstract protected function getNormalizedPostEntity();
288 * Returns the normalized PATCH entity.
290 * By default, reuses ::getNormalizedPostEntity(), which works fine for most
291 * entity types. A counterexample: the 'comment' entity type.
297 protected function getNormalizedPatchEntity() {
298 return $this->getNormalizedPostEntity();
302 * Gets the second normalized POST entity.
304 * Entity types can have non-sequential IDs, and in that case the second
305 * entity created for POST testing needs to be able to specify a different ID.
308 * @see ::getNormalizedPostEntity
311 * An array structure as returned by ::getNormalizedPostEntity().
313 protected function getSecondNormalizedPostEntity() {
314 // Return the values of the "parent" method by default.
315 return $this->getNormalizedPostEntity();
319 * Gets the normalized POST entity with random values for its unique fields.
322 * @see ::getNormalizedPostEntity
325 * An array structure as returned by ::getNormalizedPostEntity().
327 protected function getModifiedEntityForPostTesting() {
328 $normalized_entity = $this->getNormalizedPostEntity();
330 // Ensure that all the unique fields of the entity type get a new random
332 foreach (static::$uniqueFieldNames as $field_name) {
333 $field_definition = $this->entity->getFieldDefinition($field_name);
334 $field_type_class = $field_definition->getItemDefinition()->getClass();
335 $normalized_entity[$field_name] = $field_type_class::generateSampleValue($field_definition);
338 return $normalized_entity;
344 protected function getExpectedUnauthorizedAccessMessage($method) {
346 if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
347 return parent::getExpectedUnauthorizedAccessMessage($method);
350 $permission = $this->entity->getEntityType()->getAdminPermission();
351 if ($permission !== FALSE) {
352 return "The '{$permission}' permission is required.";
355 $http_method_to_entity_operation = [
359 'DELETE' => 'delete',
361 $operation = $http_method_to_entity_operation[$method];
362 $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
364 if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
365 $message .= ' of bundle ' . $this->entity->bundle();
374 protected function getExpectedUnauthorizedAccessCacheability() {
375 return (new CacheableMetadata())
376 ->setCacheTags(static::$auth
377 ? ['4xx-response', 'http_response']
378 : ['4xx-response', 'config:user.role.anonymous', 'http_response'])
379 ->setCacheContexts(['user.permissions']);
383 * The expected cache tags for the GET/HEAD response of the test entity.
389 protected function getExpectedCacheTags() {
390 $expected_cache_tags = [
391 'config:rest.resource.entity.' . static::$entityTypeId,
392 // Necessary for 'bc_entity_resource_permissions'.
393 // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
394 'config:rest.settings',
396 if (!static::$auth) {
397 $expected_cache_tags[] = 'config:user.role.anonymous';
399 $expected_cache_tags[] = 'http_response';
400 return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
404 * The expected cache contexts for the GET/HEAD response of the test entity.
410 protected function getExpectedCacheContexts() {
418 * Test a GET request for an entity, plus edge cases to ensure good DX.
420 public function testGet() {
421 $this->initAuthentication();
422 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
424 // The URL and Guzzle request options that will be used in this test. The
425 // request options will be modified/expanded throughout this test:
426 // - to first test all mistakes a developer might make, and assert that the
427 // error responses provide a good DX
428 // - to eventually result in a well-formed request that succeeds.
429 $url = $this->getEntityResourceUrl();
430 $request_options = [];
432 // DX: 404 when resource not provisioned, 403 if canonical route. HTML
433 // response because missing ?_format query string.
434 $response = $this->request('GET', $url, $request_options);
435 $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
436 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
438 $url->setOption('query', ['_format' => static::$format]);
440 // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
441 // response because ?_format query string is present.
442 $response = $this->request('GET', $url, $request_options);
443 if ($has_canonical_url) {
444 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
447 $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
450 $this->provisionEntityResource();
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 // First: single format. Drupal will automatically pick the only format.
476 $this->provisionEntityResource(TRUE);
477 $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
478 // DX: 403 because unauthorized single-format route, ?_format is omittable.
479 $url->setOption('query', []);
480 $response = $this->request('GET', $url, $request_options);
481 if ($has_canonical_url) {
482 $this->assertSame(403, $response->getStatusCode());
483 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
486 $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
488 $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
489 // DX: 403 because unauthorized.
490 $url->setOption('query', ['_format' => static::$format]);
491 $response = $this->request('GET', $url, $request_options);
492 $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', $has_canonical_url ? 'MISS' : 'HIT');
494 // Then, what we'll use for the remainder of the test: multiple formats.
495 $this->provisionEntityResource();
496 // DX: 406 because despite unauthorized, ?_format is not omittable.
497 $url->setOption('query', []);
498 $response = $this->request('GET', $url, $request_options);
499 if ($has_canonical_url) {
500 $this->assertSame(403, $response->getStatusCode());
501 $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Dynamic-Cache'));
504 $this->assertSame(406, $response->getStatusCode());
505 $this->assertSame(['UNCACHEABLE'], $response->getHeader('X-Drupal-Dynamic-Cache'));
507 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
508 $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
509 // DX: 403 because unauthorized.
510 $url->setOption('query', ['_format' => static::$format]);
511 $response = $this->request('GET', $url, $request_options);
512 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'HIT');
513 $this->assertArrayNotHasKey('Link', $response->getHeaders());
515 $this->setUpAuthorization('GET');
517 // 200 for well-formed HEAD request.
518 $response = $this->request('HEAD', $url, $request_options);
519 $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
520 $head_headers = $response->getHeaders();
522 // 200 for well-formed GET request. Page Cache hit because of HEAD request.
523 // Same for Dynamic Page Cache hit.
524 $response = $this->request('GET', $url, $request_options);
525 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', static::$auth ? 'HIT' : 'MISS');
526 // Assert that Dynamic Page Cache did not store a ResourceResponse object,
527 // which needs serialization after every cache hit. Instead, it should
528 // contain a flattened response. Otherwise performance suffers.
529 // @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
530 $cache_items = $this->container->get('database')
531 ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
532 ':pattern' => '%[route]=rest.%',
534 ->fetchAllAssoc('cid');
535 $this->assertTrue(count($cache_items) >= 2);
536 $found_cache_redirect = FALSE;
537 $found_cached_200_response = FALSE;
538 $other_cached_responses_are_4xx = TRUE;
539 foreach ($cache_items as $cid => $cache_item) {
540 $cached_data = unserialize($cache_item->data);
541 if (!isset($cached_data['#cache_redirect'])) {
542 $cached_response = $cached_data['#response'];
543 if ($cached_response->getStatusCode() === 200) {
544 $found_cached_200_response = TRUE;
546 elseif (!$cached_response->isClientError()) {
547 $other_cached_responses_are_4xx = FALSE;
549 $this->assertNotInstanceOf(ResourceResponseInterface::class, $cached_response);
550 $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
553 $found_cache_redirect = TRUE;
556 $this->assertTrue($found_cache_redirect);
557 $this->assertTrue($found_cached_200_response);
558 $this->assertTrue($other_cached_responses_are_4xx);
560 // Sort the serialization data first so we can do an identical comparison
561 // for the keys with the array order the same (it needs to match with
562 // identical comparison).
563 $expected = $this->getExpectedNormalizedEntity();
564 static::recursiveKSort($expected);
565 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
566 static::recursiveKSort($actual);
567 $this->assertSame($expected, $actual);
569 // Not only assert the normalization, also assert deserialization of the
570 // response results in the expected object.
571 // Note: deserialization of the XML format is not supported, so only test
572 // this for other formats.
573 if (static::$format !== 'xml') {
574 $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
575 $this->assertSame($unserialized->uuid(), $this->entity->uuid());
578 // Finally, assert that the expected 'Link' headers are present.
579 if ($this->entity->getEntityType()->getLinkTemplates()) {
580 $this->assertArrayHasKey('Link', $response->getHeaders());
581 $link_relation_type_manager = $this->container->get('plugin.manager.link_relation_type');
582 $expected_link_relation_headers = array_map(function ($relation_name) use ($link_relation_type_manager) {
583 $link_relation_type = $link_relation_type_manager->createInstance($relation_name);
584 return $link_relation_type->isRegistered()
585 ? $link_relation_type->getRegisteredName()
586 : $link_relation_type->getExtensionUri();
587 }, array_keys($this->entity->getEntityType()->getLinkTemplates()));
588 $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
590 if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
595 $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
597 $get_headers = $response->getHeaders();
599 // Verify that the GET and HEAD responses are the same. The only difference
600 // is that there's no body. For this reason the 'Transfer-Encoding' and
601 // 'Vary' headers are also added to the list of headers to ignore, as they
602 // may be added to GET requests, depending on web server configuration. They
603 // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
604 $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary'];
605 $header_cleaner = function ($headers) use ($ignored_headers) {
606 foreach ($headers as $header => $value) {
607 if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
608 unset($headers[$header]);
613 $get_headers = $header_cleaner($get_headers);
614 $head_headers = $header_cleaner($head_headers);
615 $this->assertSame($get_headers, $head_headers);
617 // BC: serialization_update_8302().
618 // Only run this for fieldable entities. It doesn't make sense for config
619 // entities as config values are already casted. They also run through the
620 // ConfigEntityNormalizer, which doesn't deal with fields individually.
621 // Also exclude entity_test_map_field — that has a "map" base field, which
622 // only became normalizable since Drupal 8.6, so its normalization
623 // containing non-stringified numbers or booleans does not break BC.
624 if ($this->entity instanceof FieldableEntityInterface && static::$entityTypeId !== 'entity_test_map_field') {
625 // Test primitive data casting BC (strings).
626 $this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
627 // Rebuild the container so new config is reflected in the addition of the
628 // PrimitiveDataNormalizer.
631 $response = $this->request('GET', $url, $request_options);
632 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
634 // Again do an identical comparison, but this time transform the expected
635 // normalized entity's values to strings. This ensures the BC layer for
636 // bc_primitives_as_strings works as expected.
637 $expected = $this->getExpectedNormalizedEntity();
638 // Config entities are not affected.
639 // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
640 $expected = static::castToString($expected);
641 static::recursiveKSort($expected);
642 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
643 static::recursiveKSort($actual);
644 $this->assertSame($expected, $actual);
646 // Reset the config value and rebuild.
647 $this->config('serialization.settings')->set('bc_primitives_as_strings', FALSE)->save(TRUE);
651 // BC: serialization_update_8401().
652 // Only run this for fieldable entities. It doesn't make sense for config
653 // entities as config values always use the raw values (as per the config
654 // schema), returned directly from the ConfigEntityNormalizer, which
655 // doesn't deal with fields individually.
656 if ($this->entity instanceof FieldableEntityInterface) {
657 // Test the BC settings for timestamp values.
658 $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
659 // Rebuild the container so new config is reflected in the addition of the
660 // TimestampItemNormalizer.
663 $response = $this->request('GET', $url, $request_options);
664 $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
666 // This ensures the BC layer for bc_timestamp_normalizer_unix works as
667 // expected. This method should be using
668 // ::formatExpectedTimestampValue() to generate the timestamp value. This
669 // will take into account the above config setting.
670 $expected = $this->getExpectedNormalizedEntity();
672 // Config entities are not affected.
673 // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
674 static::recursiveKSort($expected);
675 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
676 static::recursiveKSort($actual);
677 $this->assertSame($expected, $actual);
679 // Reset the config value and rebuild.
680 $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
684 // BC: rest_update_8203().
685 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
686 $this->refreshTestStateAfterRestConfigChange();
688 // DX: 403 when unauthorized.
689 $response = $this->request('GET', $url, $request_options);
690 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
692 $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
694 // 200 for well-formed request.
695 $response = $this->request('GET', $url, $request_options);
696 $expected_cache_tags = $this->getExpectedCacheTags();
697 $expected_cache_contexts = $this->getExpectedCacheContexts();
698 // @todo Fix BlockAccessControlHandler::mergeCacheabilityFromConditions() in
699 // https://www.drupal.org/node/2867881
700 if (static::$entityTypeId === 'block') {
701 $expected_cache_contexts = Cache::mergeContexts($expected_cache_contexts, ['user.permissions']);
703 // \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies to
704 // cacheable anonymous responses: it updates their cacheability. Therefore
705 // we must update our cacheability expectations for anonymous responses
707 if (!static::$auth && in_array('user.permissions', $expected_cache_contexts, TRUE)) {
708 $expected_cache_tags = Cache::mergeTags($expected_cache_tags, ['config:user.role.anonymous']);
710 $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
712 $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
713 $this->refreshTestStateAfterRestConfigChange();
715 // DX: upon disabling a resource, it's immediately no longer available.
716 $this->assertResourceNotAvailable($url, $request_options);
718 $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
719 $this->refreshTestStateAfterRestConfigChange();
721 // DX: upon re-enabling a resource, immediate 200.
722 $response = $this->request('GET', $url, $request_options);
723 $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
725 $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
726 $this->refreshTestStateAfterRestConfigChange();
728 // DX: upon deleting a resource, it's immediately no longer available.
729 $this->assertResourceNotAvailable($url, $request_options);
731 $this->provisionEntityResource();
732 $url->setOption('query', ['_format' => 'non_existing_format']);
734 // DX: 406 when requesting unsupported format.
735 $response = $this->request('GET', $url, $request_options);
736 $this->assert406Response($response);
737 $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
739 $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
741 // DX: 406 when requesting unsupported format but specifying Accept header:
742 // should result in a text/plain response.
743 $response = $this->request('GET', $url, $request_options);
744 $this->assert406Response($response);
745 $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
747 $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET');
748 $url->setRouteParameter(static::$entityTypeId, 987654321);
749 $url->setOption('query', ['_format' => static::$format]);
751 // DX: 404 when GETting non-existing entity.
752 $response = $this->request('GET', $url, $request_options);
753 $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
754 $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET")';
755 $this->assertResourceErrorResponse(404, $message, $response);
759 * Transforms a normalization: casts all non-string types to strings.
761 * @param array $normalization
762 * A normalization to transform.
765 * The transformed normalization.
767 protected static function castToString(array $normalization) {
768 foreach ($normalization as $key => $value) {
769 if (is_bool($value)) {
770 $normalization[$key] = (string) (int) $value;
772 elseif (is_int($value) || is_float($value)) {
773 $normalization[$key] = (string) $value;
775 elseif (is_array($value)) {
776 $normalization[$key] = static::castToString($value);
779 return $normalization;
783 * Tests a POST request for an entity, plus edge cases to ensure good DX.
785 public function testPost() {
786 // @todo Remove this in https://www.drupal.org/node/2300677.
787 if ($this->entity instanceof ConfigEntityInterface) {
788 $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
792 $this->initAuthentication();
793 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
795 // Try with all of the following request bodies.
796 $unparseable_request_body = '!{>}<';
797 $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
798 $parseable_valid_request_body_2 = $this->serializer->encode($this->getSecondNormalizedPostEntity(), static::$format);
799 $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity(), 'label'), static::$format);
800 $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format);
801 $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
803 // The URL and Guzzle request options that will be used in this test. The
804 // request options will be modified/expanded throughout this test:
805 // - to first test all mistakes a developer might make, and assert that the
806 // error responses provide a good DX
807 // - to eventually result in a well-formed request that succeeds.
808 $url = $this->getEntityResourcePostUrl();
809 $request_options = [];
811 // DX: 404 when resource not provisioned. HTML response because missing
812 // ?_format query string.
813 $response = $this->request('POST', $url, $request_options);
814 $this->assertSame(404, $response->getStatusCode());
815 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
817 $url->setOption('query', ['_format' => static::$format]);
819 // DX: 404 when resource not provisioned.
820 $response = $this->request('POST', $url, $request_options);
821 $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getEntityResourcePostUrl()->setAbsolute()->toString()) . '"', $response);
823 $this->provisionEntityResource();
824 // Simulate the developer again forgetting the ?_format query string.
825 $url->setOption('query', []);
827 // DX: 415 when no Content-Type request header. HTML response because
828 // missing ?_format query string.
829 $response = $this->request('POST', $url, $request_options);
830 $this->assertSame(415, $response->getStatusCode());
831 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
832 $this->assertContains('A client error happened', (string) $response->getBody());
834 $url->setOption('query', ['_format' => static::$format]);
836 // DX: 415 when no Content-Type request header.
837 $response = $this->request('POST', $url, $request_options);
838 $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
840 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
842 // DX: 400 when no request body.
843 $response = $this->request('POST', $url, $request_options);
844 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
846 $request_options[RequestOptions::BODY] = $unparseable_request_body;
848 // DX: 400 when unparseable request body.
849 $response = $this->request('POST', $url, $request_options);
850 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
852 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
855 // DX: forgetting authentication: authentication provider-specific error
857 $response = $this->request('POST', $url, $request_options);
858 $this->assertResponseWhenMissingAuthentication('POST', $response);
861 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
863 // DX: 403 when unauthorized.
864 $response = $this->request('POST', $url, $request_options);
865 // @todo Remove this if-test in https://www.drupal.org/project/drupal/issues/2820364
866 if (static::$entityTypeId === 'media' && !static::$auth) {
867 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nname: Name: this field cannot hold more than 1 values.\nfield_media_file.0: You do not have access to the referenced entity (file: 3).\n", $response);
870 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
873 $this->setUpAuthorization('POST');
875 // DX: 422 when invalid entity: multiple values sent for single-value field.
876 $response = $this->request('POST', $url, $request_options);
877 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
878 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
879 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
881 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
883 // DX: 422 when invalid entity: UUID field too long.
884 // @todo Fix this in https://www.drupal.org/node/2149851.
885 if ($this->entity->getEntityType()->hasKey('uuid')) {
886 $response = $this->request('POST', $url, $request_options);
887 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
890 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
892 // DX: 403 when entity contains field without 'edit' access.
893 $response = $this->request('POST', $url, $request_options);
894 $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
896 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
898 // Before sending a well-formed request, allow the normalization and
899 // authentication provider edge cases to also be tested.
900 $this->assertNormalizationEdgeCases('POST', $url, $request_options);
901 $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
903 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
905 // DX: 415 when request body in existing but not allowed format.
906 $response = $this->request('POST', $url, $request_options);
907 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
909 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
911 // 201 for well-formed request.
912 $response = $this->request('POST', $url, $request_options);
913 $this->assertResourceResponse(201, FALSE, $response);
914 if ($has_canonical_url) {
915 $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
916 $this->assertSame([$location], $response->getHeader('Location'));
919 $this->assertSame([], $response->getHeader('Location'));
921 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
922 // If the entity is stored, perform extra checks.
923 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
924 // Assert that the entity was indeed created, and that the response body
925 // contains the serialized created entity.
926 $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
927 $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
928 $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
929 $this->assertStoredEntityMatchesSentNormalization($this->getNormalizedPostEntity(), $created_entity);
932 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
933 $this->refreshTestStateAfterRestConfigChange();
934 $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
936 // DX: 403 when unauthorized.
937 $response = $this->request('POST', $url, $request_options);
938 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
940 $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
942 // 201 for well-formed request.
943 // If the entity is stored, delete the first created entity (in case there
944 // is a uniqueness constraint).
945 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
946 $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
948 $response = $this->request('POST', $url, $request_options);
949 $this->assertResourceResponse(201, FALSE, $response);
950 $created_entity = $this->entityStorage->load(static::$secondCreatedEntityId);
951 if ($has_canonical_url) {
952 $location = $created_entity->toUrl('canonical')->setAbsolute(TRUE)->toString();
953 $this->assertSame([$location], $response->getHeader('Location'));
956 $this->assertSame([], $response->getHeader('Location'));
958 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
960 if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
961 // 500 when creating an entity with a duplicate UUID.
962 $normalized_entity = $this->getModifiedEntityForPostTesting();
963 $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $created_entity->uuid()]];
964 $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
965 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
967 $response = $this->request('POST', $url, $request_options);
968 $this->assertSame(500, $response->getStatusCode());
969 $this->assertContains('Internal Server Error', (string) $response->getBody());
971 // 201 when successfully creating an entity with a new UUID.
972 $normalized_entity = $this->getModifiedEntityForPostTesting();
973 $new_uuid = \Drupal::service('uuid')->generate();
974 $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $new_uuid]];
975 $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
976 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
978 $response = $this->request('POST', $url, $request_options);
979 $this->assertResourceResponse(201, FALSE, $response);
980 $entities = $this->entityStorage->loadByProperties([$created_entity->getEntityType()->getKey('uuid') => $new_uuid]);
981 $new_entity = reset($entities);
982 $this->assertNotNull($new_entity);
983 $new_entity->delete();
986 // BC: old default POST URLs have their path updated by the inbound path
987 // processor \Drupal\rest\PathProcessor\PathProcessorEntityResourceBC to the
988 // new URL, which is derived from the 'create' link template if an entity
989 // type specifies it.
990 if ($this->entity->getEntityType()->hasLinkTemplate('create')) {
991 $this->entityStorage->load(static::$secondCreatedEntityId)->delete();
992 $old_url = Url::fromUri('base:entity/' . static::$entityTypeId);
993 $old_url->setOption('query', ['_format' => static::$format]);
994 $response = $this->request('POST', $old_url, $request_options);
995 $this->assertResourceResponse(201, FALSE, $response);
1000 * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
1002 public function testPatch() {
1003 // @todo Remove this in https://www.drupal.org/node/2300677.
1004 if ($this->entity instanceof ConfigEntityInterface) {
1005 $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
1009 // Patch testing requires that another entity of the same type exists.
1010 $this->anotherEntity = $this->createAnotherEntity();
1012 $this->initAuthentication();
1013 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1015 // Try with all of the following request bodies.
1016 $unparseable_request_body = '!{>}<';
1017 $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1018 $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1019 $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'label'), static::$format);
1020 $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
1021 // The 'field_rest_test' field does not allow 'view' access, so does not end
1022 // up in the normalization. Even when we explicitly add it the normalization
1023 // that we send in the body of a PATCH request, it is considered invalid.
1024 $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], static::$format);
1026 // The URL and Guzzle request options that will be used in this test. The
1027 // request options will be modified/expanded throughout this test:
1028 // - to first test all mistakes a developer might make, and assert that the
1029 // error responses provide a good DX
1030 // - to eventually result in a well-formed request that succeeds.
1031 $url = $this->getEntityResourceUrl();
1032 $request_options = [];
1034 // DX: 404 when resource not provisioned, 405 if canonical route. Plain text
1035 // or HTML response because missing ?_format query string.
1036 $response = $this->request('PATCH', $url, $request_options);
1037 if ($has_canonical_url) {
1038 $this->assertSame(405, $response->getStatusCode());
1039 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1040 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1041 $this->assertContains('A client error happened', (string) $response->getBody());
1044 $this->assertSame(404, $response->getStatusCode());
1045 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1048 $url->setOption('query', ['_format' => static::$format]);
1050 // DX: 404 when resource not provisioned, 405 if canonical route.
1051 $response = $this->request('PATCH', $url, $request_options);
1052 if ($has_canonical_url) {
1053 $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1056 $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1059 $this->provisionEntityResource();
1060 // Simulate the developer again forgetting the ?_format query string.
1061 $url->setOption('query', []);
1063 // DX: 415 when no Content-Type request header.
1064 $response = $this->request('PATCH', $url, $request_options);
1065 $this->assertSame(415, $response->getStatusCode());
1066 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1067 $this->assertContains('A client error happened', (string) $response->getBody());
1069 $url->setOption('query', ['_format' => static::$format]);
1071 // DX: 415 when no Content-Type request header.
1072 $response = $this->request('PATCH', $url, $request_options);
1073 $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
1075 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1077 // DX: 400 when no request body.
1078 $response = $this->request('PATCH', $url, $request_options);
1079 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
1081 $request_options[RequestOptions::BODY] = $unparseable_request_body;
1083 // DX: 400 when unparseable request body.
1084 $response = $this->request('PATCH', $url, $request_options);
1085 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
1087 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
1089 if (static::$auth) {
1090 // DX: forgetting authentication: authentication provider-specific error
1092 $response = $this->request('PATCH', $url, $request_options);
1093 $this->assertResponseWhenMissingAuthentication('PATCH', $response);
1096 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1098 // DX: 403 when unauthorized.
1099 $response = $this->request('PATCH', $url, $request_options);
1100 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1102 $this->setUpAuthorization('PATCH');
1104 // DX: 422 when invalid entity: multiple values sent for single-value field.
1105 $response = $this->request('PATCH', $url, $request_options);
1106 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
1107 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
1108 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
1110 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
1112 // DX: 403 when entity contains field without 'edit' access.
1113 $response = $this->request('PATCH', $url, $request_options);
1114 $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1116 // DX: 403 when entity trying to update an entity's ID field.
1117 $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'id'), static::$format);;
1118 $response = $this->request('PATCH', $url, $request_options);
1119 $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'. The entity ID cannot be changed.", $response);
1121 if ($this->entity->getEntityType()->hasKey('uuid')) {
1122 // DX: 403 when entity trying to update an entity's UUID field.
1123 $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'uuid'), static::$format);;
1124 $response = $this->request('PATCH', $url, $request_options);
1125 $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'. The entity UUID cannot be changed.", $response);
1128 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
1130 // DX: 403 when entity contains field without 'edit' nor 'view' access, even
1131 // when the value for that field matches the current value. This is allowed
1132 // in principle, but leads to information disclosure.
1133 $response = $this->request('PATCH', $url, $request_options);
1134 $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1136 // DX: 403 when sending PATCH request with updated read-only fields.
1137 $this->assertPatchProtectedFieldNamesStructure();
1138 list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
1139 // Send PATCH request by serializing the modified entity, assert the error
1140 // response, change the modified entity field that caused the error response
1141 // back to its original value, repeat.
1142 foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
1143 $request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
1144 $response = $this->request('PATCH', $url, $request_options);
1145 $this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'." . ($reason !== NULL ? ' ' . $reason : ''), $response);
1146 $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
1149 if ($this->entity instanceof FieldableEntityInterface) {
1150 // Change the rest_test_validation field to prove that then its validation
1153 'rest_test_validation' => [
1155 'value' => 'ALWAYS_FAIL',
1159 $valid_request_body = $override + $this->getNormalizedPatchEntity() + $this->serializer->normalize($modified_entity, static::$format);
1160 $request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
1161 $response = $this->request('PATCH', $url, $request_options);
1162 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nrest_test_validation: REST test validation failed\n", $response);
1164 // Set the rest_test_validation field to always fail validation, which
1165 // allows asserting that not modifying that field does not trigger
1166 // validation errors.
1167 $this->entity->set('rest_test_validation', 'ALWAYS_FAIL');
1168 $this->entity->save();
1170 // Information disclosure prevented: when a malicious user correctly
1171 // guesses the current invalid value of a field, ensure a 200 is not sent
1172 // because this would disclose to the attacker what the current value is.
1173 // @see rest_test_entity_field_access()
1174 $response = $this->request('PATCH', $url, $request_options);
1175 $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nrest_test_validation: REST test validation failed\n", $response);
1177 // All requests after the above one will not include this field (neither
1178 // its current value nor any other), and therefore all subsequent test
1179 // assertions should not trigger a validation error.
1182 // 200 for well-formed PATCH request that sends all fields (even including
1183 // read-only ones, but with unchanged values).
1184 $valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
1185 $request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
1186 $response = $this->request('PATCH', $url, $request_options);
1187 $this->assertResourceResponse(200, FALSE, $response);
1189 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
1191 // Before sending a well-formed request, allow the normalization and
1192 // authentication provider edge cases to also be tested.
1193 $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
1194 $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
1196 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
1198 // DX: 415 when request body in existing but not allowed format.
1199 $response = $this->request('PATCH', $url, $request_options);
1200 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
1202 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1204 // 200 for well-formed request.
1205 $response = $this->request('PATCH', $url, $request_options);
1206 $this->assertResourceResponse(200, FALSE, $response);
1207 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1208 // Assert that the entity was indeed updated, and that the response body
1209 // contains the serialized updated entity.
1210 $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
1211 $updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]);
1212 $this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
1213 $this->assertStoredEntityMatchesSentNormalization($this->getNormalizedPatchEntity(), $updated_entity);
1214 // Ensure that fields do not get deleted if they're not present in the PATCH
1215 // request. Test this using the configurable field that we added, but which
1216 // is not sent in the PATCH request.
1217 $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);
1219 // Multi-value field: remove item 0. Then item 1 becomes item 0.
1220 $normalization_multi_value_tests = $this->getNormalizedPatchEntity();
1221 $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
1222 $normalization_remove_item = $normalization_multi_value_tests;
1223 unset($normalization_remove_item['field_rest_test_multivalue'][0]);
1224 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format);
1225 $response = $this->request('PATCH', $url, $request_options);
1226 $this->assertResourceResponse(200, FALSE, $response);
1227 $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1229 // Multi-value field: add one item before the existing one, and one after.
1230 $normalization_add_items = $normalization_multi_value_tests;
1231 $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three'];
1232 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format);
1233 $response = $this->request('PATCH', $url, $request_options);
1234 $this->assertResourceResponse(200, FALSE, $response);
1235 $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1237 // BC: rest_update_8203().
1238 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1239 $this->refreshTestStateAfterRestConfigChange();
1240 $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
1242 // DX: 403 when unauthorized.
1243 $response = $this->request('PATCH', $url, $request_options);
1244 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1246 $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
1248 // 200 for well-formed request.
1249 $response = $this->request('PATCH', $url, $request_options);
1250 $this->assertResourceResponse(200, FALSE, $response);
1251 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1255 * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
1257 public function testDelete() {
1258 // @todo Remove this in https://www.drupal.org/node/2300677.
1259 if ($this->entity instanceof ConfigEntityInterface) {
1260 $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
1264 $this->initAuthentication();
1265 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1267 // The URL and Guzzle request options that will be used in this test. The
1268 // request options will be modified/expanded throughout this test:
1269 // - to first test all mistakes a developer might make, and assert that the
1270 // error responses provide a good DX
1271 // - to eventually result in a well-formed request that succeeds.
1272 $url = $this->getEntityResourceUrl();
1273 $request_options = [];
1275 // DX: 404 when resource not provisioned, but 405 if canonical route. Plain
1276 // text or HTML response because missing ?_format query string.
1277 $response = $this->request('DELETE', $url, $request_options);
1278 if ($has_canonical_url) {
1279 $this->assertSame(405, $response->getStatusCode());
1280 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1281 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1282 $this->assertContains('A client error happened', (string) $response->getBody());
1285 $this->assertSame(404, $response->getStatusCode());
1286 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1289 $url->setOption('query', ['_format' => static::$format]);
1291 // DX: 404 when resource not provisioned, 405 if canonical route.
1292 $response = $this->request('DELETE', $url, $request_options);
1293 if ($has_canonical_url) {
1294 $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1295 $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1298 $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1301 $this->provisionEntityResource();
1303 if (static::$auth) {
1304 // DX: forgetting authentication: authentication provider-specific error
1306 $response = $this->request('DELETE', $url, $request_options);
1307 $this->assertResponseWhenMissingAuthentication('DELETE', $response);
1310 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1312 // DX: 403 when unauthorized.
1313 $response = $this->request('DELETE', $url, $request_options);
1314 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1316 $this->setUpAuthorization('DELETE');
1318 // Before sending a well-formed request, allow the authentication provider's
1319 // edge cases to also be tested.
1320 $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
1322 // 204 for well-formed request.
1323 $response = $this->request('DELETE', $url, $request_options);
1324 $this->assertResourceResponse(204, '', $response);
1326 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1327 $this->refreshTestStateAfterRestConfigChange();
1328 $this->entity = $this->createEntity();
1329 $url = $this->getEntityResourceUrl()->setOption('query', $url->getOption('query'));
1331 // DX: 403 when unauthorized.
1332 $response = $this->request('DELETE', $url, $request_options);
1333 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1335 $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
1337 // 204 for well-formed request.
1338 $response = $this->request('DELETE', $url, $request_options);
1339 $this->assertResourceResponse(204, '', $response);
1345 protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
1346 // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
1347 // types with bundles MUST send their bundle field to be denormalizable.
1348 $entity_type = $this->entity->getEntityType();
1349 if ($entity_type->hasKey('bundle')) {
1350 $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
1351 $normalization = $this->getNormalizedPostEntity();
1353 // The bundle type itself can be validated only if there's a bundle entity
1355 if ($entity_type->getBundleEntityType()) {
1356 $normalization[$bundle_field_name] = 'bad_bundle_name';
1357 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1359 // DX: 422 when incorrect entity type bundle is specified.
1360 $response = $this->request($method, $url, $request_options);
1361 $this->assertResourceErrorResponse(422, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
1364 unset($normalization[$bundle_field_name]);
1365 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1367 // DX: 422 when no entity type bundle is specified.
1368 $response = $this->request($method, $url, $request_options);
1369 $this->assertResourceErrorResponse(422, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response);
1374 * Asserts structure of $patchProtectedFieldNames.
1376 protected function assertPatchProtectedFieldNamesStructure() {
1377 $is_null_or_string = function ($value) {
1378 return is_null($value) || is_string($value);
1380 $keys_are_field_names = Inspector::assertAllStrings(array_keys(static::$patchProtectedFieldNames));
1381 $values_are_expected_access_denied_reasons = Inspector::assertAll($is_null_or_string, static::$patchProtectedFieldNames);
1382 $this->assertTrue($keys_are_field_names && $values_are_expected_access_denied_reasons, 'In Drupal 8.6, the structure of $patchProtectectedFieldNames changed. It used to be an array with field names as values. Now those values are the keys, and their values should be either NULL or a string: a string containing the reason for why the field cannot be PATCHed, or NULL otherwise.');
1386 * Gets an entity resource's GET/PATCH/DELETE URL.
1388 * @return \Drupal\Core\Url
1389 * The URL to GET/PATCH/DELETE.
1391 protected function getEntityResourceUrl() {
1392 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1393 // Note that the 'canonical' link relation type must be specified explicitly
1394 // in the call to ::toUrl(). 'canonical' is the default for
1395 // \Drupal\Core\Entity\Entity::toUrl(), but ConfigEntityBase overrides this.
1396 return $has_canonical_url ? $this->entity->toUrl('canonical') : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
1400 * Gets an entity resource's POST URL.
1402 * @return \Drupal\Core\Url
1403 * The URL to POST to.
1405 protected function getEntityResourcePostUrl() {
1406 $has_create_url = $this->entity->hasLinkTemplate('create');
1407 return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId);
1411 * Clones the given entity and modifies all PATCH-protected fields.
1413 * @param \Drupal\Core\Entity\EntityInterface $entity
1414 * The entity being tested and to modify.
1417 * Contains two items:
1418 * 1. The modified entity object.
1419 * 2. The original field values, keyed by field name.
1423 protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
1424 $modified_entity = clone $entity;
1425 $original_values = [];
1426 foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) {
1427 $field = $modified_entity->get($field_name);
1428 $original_values[$field_name] = $field->getValue();
1429 switch ($field->getItemDefinition()->getClass()) {
1430 case EntityReferenceItem::class:
1431 // EntityReferenceItem::generateSampleValue() picks one of the last 50
1432 // entities of the supported type & bundle. We don't care if the value
1433 // is valid, we only care that it's different.
1434 $field->setValue(['target_id' => 99999]);
1436 case BooleanItem::class:
1437 // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
1438 // chance of not picking a different value.
1439 $field->value = ((int) $field->value) === 1 ? '0' : '1';
1441 case PathItem::class:
1442 // PathItem::generateSampleValue() doesn't set a PID, which causes
1443 // PathItem::postSave() to fail. Keep the PID (and other properties),
1444 // just modify the alias.
1445 $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
1448 $original_field = clone $field;
1449 while ($field->equals($original_field)) {
1450 $field->generateSampleItems();
1456 return [$modified_entity, $original_values];
1460 * Makes the given entity normalization invalid.
1462 * @param array $normalization
1463 * An entity normalization.
1464 * @param string $entity_key
1465 * The entity key whose normalization to make invalid.
1468 * The updated entity normalization, now invalid.
1470 protected function makeNormalizationInvalid(array $normalization, $entity_key) {
1471 $entity_type = $this->entity->getEntityType();
1472 switch ($entity_key) {
1474 // Add a second label to this entity to make it invalid.
1475 $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
1476 $normalization[$label_field][1]['value'] = 'Second Title';
1479 $normalization[$entity_type->getKey('id')][0]['value'] = $this->anotherEntity->id();
1482 $normalization[$entity_type->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid();
1485 return $normalization;
1489 * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1491 * Asserting a 406 response should be easy, but it's not, due to bugs.
1493 * Drupal returns a 403 response instead of a 406 response when:
1494 * - there is a canonical route, i.e. one that serves HTML
1495 * - unless the user is logged in with any non-global authentication provider,
1496 * because then they tried to access a route that requires the user to be
1497 * authenticated, but they used an authentication provider that is only
1498 * accepted for specific routes, and HTML routes never have such specific
1499 * authentication providers specified. (By default, only 'cookie' is a
1500 * global authentication provider.)
1502 * @todo Remove this in https://www.drupal.org/node/2805279.
1504 * @param \Psr\Http\Message\ResponseInterface $response
1505 * The response to assert.
1507 protected function assert406Response(ResponseInterface $response) {
1508 if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1509 $this->assertSame(403, $response->getStatusCode());
1512 // This is the desired response.
1513 $this->assertSame(406, $response->getStatusCode());
1514 $this->stringContains('?_format=' . static::$format . '>; rel="alternate"; type="' . static::$mimeType . '"', $response->getHeader('Link'));
1515 $this->stringContains('?_format=foobar>; rel="alternate"', $response->getHeader('Link'));
1520 * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1522 * @param \Drupal\Core\Url $url
1524 * @param array $request_options
1525 * Request options to apply.
1527 protected function assertResourceNotAvailable(Url $url, array $request_options) {
1528 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1529 $response = $this->request('GET', $url, $request_options);
1530 if (!$has_canonical_url) {
1531 $this->assertSame(404, $response->getStatusCode());
1534 $this->assert406Response($response);
1539 * Asserts that the stored entity matches the sent normalization.
1541 * @param array $sent_normalization
1542 * An entity normalization.
1543 * @param \Drupal\Core\Entity\FieldableEntityInterface $modified_entity
1544 * The entity object of the modified (PATCHed or POSTed) entity.
1546 protected function assertStoredEntityMatchesSentNormalization(array $sent_normalization, FieldableEntityInterface $modified_entity) {
1547 foreach ($sent_normalization as $field_name => $field_normalization) {
1548 // Some top-level keys in the normalization may not be fields on the
1549 // entity (for example '_links' and '_embedded' in the HAL normalization).
1550 if ($modified_entity->hasField($field_name)) {
1551 $field_type = $modified_entity->get($field_name)->getFieldDefinition()->getType();
1552 // Fields are stored in the database, when read they are represented
1553 // as strings in PHP memory. The exception: field types that are
1554 // stored in a serialized way. Hence we need to cast most expected
1555 // field normalizations to strings.
1556 $expected_field_normalization = ($field_type !== 'map')
1557 ? static::castToString($field_normalization)
1558 : $field_normalization;
1559 // Subset, not same, because we can e.g. send just the target_id for the
1560 // bundle in a PATCH or POST request; the response will include more
1562 $this->assertArraySubset($expected_field_normalization, $modified_entity->get($field_name)->getValue(), TRUE);