17e0c0a7b5fd230a7d4bef98922f5bed24b758d4
[yaffs-website] / web / core / modules / rest / tests / src / Functional / EntityResource / EntityResourceTestBase.php
1 <?php
2
3 namespace Drupal\Tests\rest\Functional\EntityResource;
4
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;
17 use Drupal\Core\Url;
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;
25
26 /**
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.
31  *
32  * Furthermore, it's necessary to test every format separately, because there
33  * can be entity type-specific normalization or serialization problems.
34  *
35  * Subclass this for every entity type. Also respect instructions in
36  * \Drupal\rest\Tests\ResourceTestBase.
37  *
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
49  *
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
53  *    grants), plus
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.
58  *
59  * For every of these concrete subclasses, a comprehensive test scenario will
60  * run per HTTP method:
61  * - ::testGet()
62  * - ::testPost()
63  * - ::testPatch()
64  * - ::testDelete()
65  *
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
69  *
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
73  */
74 abstract class EntityResourceTestBase extends ResourceTestBase {
75
76   /**
77    * The tested entity type.
78    *
79    * @var string
80    */
81   protected static $entityTypeId = NULL;
82
83   /**
84    * The fields that are protected against modification during PATCH requests.
85    *
86    * Keys are field names, values are expected access denied reasons.
87    *
88    * @var string[]
89    */
90   protected static $patchProtectedFieldNames;
91
92   /**
93    * The fields that need a different (random) value for each new entity created
94    * by a POST request.
95    *
96    * @var string[]
97    */
98   protected static $uniqueFieldNames = [];
99
100   /**
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.
103    *
104    * @see ::getInvalidNormalizedEntityToCreate
105    *
106    * @var string|null
107    */
108   protected static $labelFieldName = NULL;
109
110   /**
111    * The entity ID for the first created entity in testPost().
112    *
113    * The default value of 2 should work for most content entities.
114    *
115    * @see ::testPost()
116    *
117    * @var string|int
118    */
119   protected static $firstCreatedEntityId = 2;
120
121   /**
122    * The entity ID for the second created entity in testPost().
123    *
124    * The default value of 3 should work for most content entities.
125    *
126    * @see ::testPost()
127    *
128    * @var string|int
129    */
130   protected static $secondCreatedEntityId = 3;
131
132   /**
133    * The main entity used for testing.
134    *
135    * @var \Drupal\Core\Entity\EntityInterface
136    */
137   protected $entity;
138
139   /**
140    * Another entity of the same type used for testing.
141    *
142    * @var \Drupal\Core\Entity\EntityInterface
143    */
144   protected $anotherEntity;
145
146   /**
147    * The entity storage.
148    *
149    * @var \Drupal\Core\Entity\EntityStorageInterface
150    */
151   protected $entityStorage;
152
153   /**
154    * Modules to install.
155    *
156    * @var array
157    */
158   public static $modules = ['rest_test', 'text'];
159
160   /**
161    * Provides an entity resource.
162    *
163    * @param bool $single_format
164    *   Provisions a single-format entity REST resource. Defaults to FALSE.
165    */
166   protected function provisionEntityResource($single_format = FALSE) {
167     if ($existing = $this->resourceConfigStorage->load(static::$resourceConfigId)) {
168       $existing->delete();
169     }
170
171     $format = $single_format
172       ? [static::$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);
178   }
179
180   /**
181    * {@inheritdoc}
182    */
183   public function setUp() {
184     parent::setUp();
185
186     // Calculate REST Resource config entity ID.
187     static::$resourceConfigId = 'entity.' . static::$entityTypeId;
188
189     $this->entityStorage = $this->container->get('entity_type.manager')
190       ->getStorage(static::$entityTypeId);
191
192     // Create an entity.
193     $this->entity = $this->createEntity();
194
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',
200         'type' => 'text',
201       ])
202         ->setCardinality(1)
203         ->save();
204       FieldConfig::create([
205         'entity_type' => static::$entityTypeId,
206         'field_name' => 'field_rest_test',
207         'bundle' => $this->entity->bundle(),
208       ])
209         ->setLabel('Test field')
210         ->setTranslatable(FALSE)
211         ->save();
212
213       // Add multi-value field.
214       FieldStorageConfig::create([
215         'entity_type' => static::$entityTypeId,
216         'field_name' => 'field_rest_test_multivalue',
217         'type' => 'string',
218       ])
219         ->setCardinality(3)
220         ->save();
221       FieldConfig::create([
222         'entity_type' => static::$entityTypeId,
223         'field_name' => 'field_rest_test_multivalue',
224         'bundle' => $this->entity->bundle(),
225       ])
226         ->setLabel('Test field: multi-value')
227         ->setTranslatable(FALSE)
228         ->save();
229
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;
235
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();
241       }
242     }
243   }
244
245   /**
246    * Creates the entity to be tested.
247    *
248    * @return \Drupal\Core\Entity\EntityInterface
249    *   The entity to be tested.
250    */
251   abstract protected function createEntity();
252
253   /**
254    * Creates another entity to be tested.
255    *
256    * @return \Drupal\Core\Entity\EntityInterface
257    *   Another entity based on $this->entity.
258    */
259   protected function createAnotherEntity() {
260     $entity = $this->entity->createDuplicate();
261     $label_key = $entity->getEntityType()->getKey('label');
262     if ($label_key) {
263       $entity->set($label_key, $entity->label() . '_dupe');
264     }
265     $entity->save();
266     return $entity;
267   }
268
269   /**
270    * Returns the expected normalization of the entity.
271    *
272    * @see ::createEntity()
273    *
274    * @return array
275    */
276   abstract protected function getExpectedNormalizedEntity();
277
278   /**
279    * Returns the normalized POST entity.
280    *
281    * @see ::testPost
282    *
283    * @return array
284    */
285   abstract protected function getNormalizedPostEntity();
286
287   /**
288    * Returns the normalized PATCH entity.
289    *
290    * By default, reuses ::getNormalizedPostEntity(), which works fine for most
291    * entity types. A counterexample: the 'comment' entity type.
292    *
293    * @see ::testPatch
294    *
295    * @return array
296    */
297   protected function getNormalizedPatchEntity() {
298     return $this->getNormalizedPostEntity();
299   }
300
301   /**
302    * Gets the second normalized POST entity.
303    *
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.
306    *
307    * @see ::testPost
308    * @see ::getNormalizedPostEntity
309    *
310    * @return array
311    *   An array structure as returned by ::getNormalizedPostEntity().
312    */
313   protected function getSecondNormalizedPostEntity() {
314     // Return the values of the "parent" method by default.
315     return $this->getNormalizedPostEntity();
316   }
317
318   /**
319    * Gets the normalized POST entity with random values for its unique fields.
320    *
321    * @see ::testPost
322    * @see ::getNormalizedPostEntity
323    *
324    * @return array
325    *   An array structure as returned by ::getNormalizedPostEntity().
326    */
327   protected function getModifiedEntityForPostTesting() {
328     $normalized_entity = $this->getNormalizedPostEntity();
329
330     // Ensure that all the unique fields of the entity type get a new random
331     // value.
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);
336     }
337
338     return $normalized_entity;
339   }
340
341   /**
342    * {@inheritdoc}
343    */
344   protected function getExpectedUnauthorizedAccessMessage($method) {
345
346     if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
347       return parent::getExpectedUnauthorizedAccessMessage($method);
348     }
349
350     $permission = $this->entity->getEntityType()->getAdminPermission();
351     if ($permission !== FALSE) {
352       return "The '{$permission}' permission is required.";
353     }
354
355     $http_method_to_entity_operation = [
356       'GET' => 'view',
357       'POST' => 'create',
358       'PATCH' => 'update',
359       'DELETE' => 'delete',
360     ];
361     $operation = $http_method_to_entity_operation[$method];
362     $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
363
364     if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
365       $message .= ' of bundle ' . $this->entity->bundle();
366     }
367
368     return "$message.";
369   }
370
371   /**
372    * {@inheritdoc}
373    */
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']);
380   }
381
382   /**
383    * The expected cache tags for the GET/HEAD response of the test entity.
384    *
385    * @see ::testGet
386    *
387    * @return string[]
388    */
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',
395     ];
396     if (!static::$auth) {
397       $expected_cache_tags[] = 'config:user.role.anonymous';
398     }
399     $expected_cache_tags[] = 'http_response';
400     return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
401   }
402
403   /**
404    * The expected cache contexts for the GET/HEAD response of the test entity.
405    *
406    * @see ::testGet
407    *
408    * @return string[]
409    */
410   protected function getExpectedCacheContexts() {
411     return [
412       'url.site',
413       'user.permissions',
414     ];
415   }
416
417   /**
418    * Test a GET request for an entity, plus edge cases to ensure good DX.
419    */
420   public function testGet() {
421     $this->initAuthentication();
422     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
423
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 = [];
431
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'));
437
438     $url->setOption('query', ['_format' => static::$format]);
439
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);
445     }
446     else {
447       $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
448     }
449
450     $this->provisionEntityResource();
451
452     // DX: forgetting authentication: authentication provider-specific error
453     // response.
454     if (static::$auth) {
455       $response = $this->request('GET', $url, $request_options);
456       $this->assertResponseWhenMissingAuthentication('GET', $response);
457     }
458
459     $request_options[RequestOptions::HEADERS]['REST-test-auth'] = '1';
460
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);
464
465     unset($request_options[RequestOptions::HEADERS]['REST-test-auth']);
466     $request_options[RequestOptions::HEADERS]['REST-test-auth-global'] = '1';
467
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);
471
472     unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']);
473     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
474
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'));
484     }
485     else {
486       $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
487     }
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');
493
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'));
502     }
503     else {
504       $this->assertSame(406, $response->getStatusCode());
505       $this->assertSame(['UNCACHEABLE'], $response->getHeader('X-Drupal-Dynamic-Cache'));
506     }
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());
514
515     $this->setUpAuthorization('GET');
516
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();
521
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.%',
533       ])
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;
545         }
546         elseif (!$cached_response->isClientError()) {
547           $other_cached_responses_are_4xx = FALSE;
548         }
549         $this->assertNotInstanceOf(ResourceResponseInterface::class, $cached_response);
550         $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
551       }
552       else {
553         $found_cache_redirect = TRUE;
554       }
555     }
556     $this->assertTrue($found_cache_redirect);
557     $this->assertTrue($found_cached_200_response);
558     $this->assertTrue($other_cached_responses_are_4xx);
559
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);
568
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());
576
577     }
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) {
589         $matches = [];
590         if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
591           return $matches[1];
592         }
593         return FALSE;
594       };
595       $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
596     }
597     $get_headers = $response->getHeaders();
598
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]);
609         }
610       }
611       return $headers;
612     };
613     $get_headers = $header_cleaner($get_headers);
614     $head_headers = $header_cleaner($head_headers);
615     $this->assertSame($get_headers, $head_headers);
616
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.
629       $this->rebuildAll();
630
631       $response = $this->request('GET', $url, $request_options);
632       $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
633
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);
645
646       // Reset the config value and rebuild.
647       $this->config('serialization.settings')->set('bc_primitives_as_strings', FALSE)->save(TRUE);
648       $this->rebuildAll();
649     }
650
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.
661       $this->rebuildAll();
662
663       $response = $this->request('GET', $url, $request_options);
664       $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
665
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();
671
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);
678
679       // Reset the config value and rebuild.
680       $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
681       $this->rebuildAll();
682     }
683
684     // BC: rest_update_8203().
685     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
686     $this->refreshTestStateAfterRestConfigChange();
687
688     // DX: 403 when unauthorized.
689     $response = $this->request('GET', $url, $request_options);
690     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
691
692     $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
693
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']);
702     }
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
706     // accordingly.
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']);
709     }
710     $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
711
712     $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
713     $this->refreshTestStateAfterRestConfigChange();
714
715     // DX: upon disabling a resource, it's immediately no longer available.
716     $this->assertResourceNotAvailable($url, $request_options);
717
718     $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
719     $this->refreshTestStateAfterRestConfigChange();
720
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');
724
725     $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
726     $this->refreshTestStateAfterRestConfigChange();
727
728     // DX: upon deleting a resource, it's immediately no longer available.
729     $this->assertResourceNotAvailable($url, $request_options);
730
731     $this->provisionEntityResource();
732     $url->setOption('query', ['_format' => 'non_existing_format']);
733
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'));
738
739     $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
740
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'));
746
747     $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET');
748     $url->setRouteParameter(static::$entityTypeId, 987654321);
749     $url->setOption('query', ['_format' => static::$format]);
750
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);
756   }
757
758   /**
759    * Transforms a normalization: casts all non-string types to strings.
760    *
761    * @param array $normalization
762    *   A normalization to transform.
763    *
764    * @return array
765    *   The transformed normalization.
766    */
767   protected static function castToString(array $normalization) {
768     foreach ($normalization as $key => $value) {
769       if (is_bool($value)) {
770         $normalization[$key] = (string) (int) $value;
771       }
772       elseif (is_int($value) || is_float($value)) {
773         $normalization[$key] = (string) $value;
774       }
775       elseif (is_array($value)) {
776         $normalization[$key] = static::castToString($value);
777       }
778     }
779     return $normalization;
780   }
781
782   /**
783    * Tests a POST request for an entity, plus edge cases to ensure good DX.
784    */
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.');
789       return;
790     }
791
792     $this->initAuthentication();
793     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
794
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);
802
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 = [];
810
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'));
816
817     $url->setOption('query', ['_format' => static::$format]);
818
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);
822
823     $this->provisionEntityResource();
824     // Simulate the developer again forgetting the ?_format query string.
825     $url->setOption('query', []);
826
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());
833
834     $url->setOption('query', ['_format' => static::$format]);
835
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);
839
840     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
841
842     // DX: 400 when no request body.
843     $response = $this->request('POST', $url, $request_options);
844     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
845
846     $request_options[RequestOptions::BODY] = $unparseable_request_body;
847
848     // DX: 400 when unparseable request body.
849     $response = $this->request('POST', $url, $request_options);
850     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
851
852     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
853
854     if (static::$auth) {
855       // DX: forgetting authentication: authentication provider-specific error
856       // response.
857       $response = $this->request('POST', $url, $request_options);
858       $this->assertResponseWhenMissingAuthentication('POST', $response);
859     }
860
861     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
862
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);
868     }
869     else {
870       $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
871     }
872
873     $this->setUpAuthorization('POST');
874
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);
880
881     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
882
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);
888     }
889
890     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
891
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);
895
896     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
897
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);
902
903     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
904
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);
908
909     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
910
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'));
917     }
918     else {
919       $this->assertSame([], $response->getHeader('Location'));
920     }
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);
930     }
931
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;
935
936     // DX: 403 when unauthorized.
937     $response = $this->request('POST', $url, $request_options);
938     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
939
940     $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
941
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();
947     }
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'));
954     }
955     else {
956       $this->assertSame([], $response->getHeader('Location'));
957     }
958     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
959
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);
966
967       $response = $this->request('POST', $url, $request_options);
968       $this->assertSame(500, $response->getStatusCode());
969       $this->assertContains('Internal Server Error', (string) $response->getBody());
970
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);
977
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();
984     }
985
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);
996     }
997   }
998
999   /**
1000    * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
1001    */
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.');
1006       return;
1007     }
1008
1009     // Patch testing requires that another entity of the same type exists.
1010     $this->anotherEntity = $this->createAnotherEntity();
1011
1012     $this->initAuthentication();
1013     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1014
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);
1025
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 = [];
1033
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());
1042     }
1043     else {
1044       $this->assertSame(404, $response->getStatusCode());
1045       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1046     }
1047
1048     $url->setOption('query', ['_format' => static::$format]);
1049
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);
1054     }
1055     else {
1056       $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1057     }
1058
1059     $this->provisionEntityResource();
1060     // Simulate the developer again forgetting the ?_format query string.
1061     $url->setOption('query', []);
1062
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());
1068
1069     $url->setOption('query', ['_format' => static::$format]);
1070
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);
1074
1075     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1076
1077     // DX: 400 when no request body.
1078     $response = $this->request('PATCH', $url, $request_options);
1079     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
1080
1081     $request_options[RequestOptions::BODY] = $unparseable_request_body;
1082
1083     // DX: 400 when unparseable request body.
1084     $response = $this->request('PATCH', $url, $request_options);
1085     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
1086
1087     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
1088
1089     if (static::$auth) {
1090       // DX: forgetting authentication: authentication provider-specific error
1091       // response.
1092       $response = $this->request('PATCH', $url, $request_options);
1093       $this->assertResponseWhenMissingAuthentication('PATCH', $response);
1094     }
1095
1096     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1097
1098     // DX: 403 when unauthorized.
1099     $response = $this->request('PATCH', $url, $request_options);
1100     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1101
1102     $this->setUpAuthorization('PATCH');
1103
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);
1109
1110     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
1111
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);
1115
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);
1120
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);
1126     }
1127
1128     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
1129
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);
1135
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]);
1147     }
1148
1149     if ($this->entity instanceof FieldableEntityInterface) {
1150       // Change the rest_test_validation field to prove that then its validation
1151       // does run.
1152       $override = [
1153         'rest_test_validation' => [
1154           [
1155             'value' => 'ALWAYS_FAIL',
1156           ],
1157         ],
1158       ];
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);
1163
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();
1169
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);
1176
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.
1180     }
1181
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);
1188
1189     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
1190
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);
1195
1196     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
1197
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);
1201
1202     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1203
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);
1218
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());
1228
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());
1236
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;
1241
1242     // DX: 403 when unauthorized.
1243     $response = $this->request('PATCH', $url, $request_options);
1244     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1245
1246     $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
1247
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'));
1252   }
1253
1254   /**
1255    * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
1256    */
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.');
1261       return;
1262     }
1263
1264     $this->initAuthentication();
1265     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1266
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 = [];
1274
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());
1283     }
1284     else {
1285       $this->assertSame(404, $response->getStatusCode());
1286       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1287     }
1288
1289     $url->setOption('query', ['_format' => static::$format]);
1290
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);
1296     }
1297     else {
1298       $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1299     }
1300
1301     $this->provisionEntityResource();
1302
1303     if (static::$auth) {
1304       // DX: forgetting authentication: authentication provider-specific error
1305       // response.
1306       $response = $this->request('DELETE', $url, $request_options);
1307       $this->assertResponseWhenMissingAuthentication('DELETE', $response);
1308     }
1309
1310     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1311
1312     // DX: 403 when unauthorized.
1313     $response = $this->request('DELETE', $url, $request_options);
1314     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1315
1316     $this->setUpAuthorization('DELETE');
1317
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);
1321
1322     // 204 for well-formed request.
1323     $response = $this->request('DELETE', $url, $request_options);
1324     $this->assertResourceResponse(204, '', $response);
1325
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'));
1330
1331     // DX: 403 when unauthorized.
1332     $response = $this->request('DELETE', $url, $request_options);
1333     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1334
1335     $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
1336
1337     // 204 for well-formed request.
1338     $response = $this->request('DELETE', $url, $request_options);
1339     $this->assertResourceResponse(204, '', $response);
1340   }
1341
1342   /**
1343    * {@inheritdoc}
1344    */
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();
1352
1353       // The bundle type itself can be validated only if there's a bundle entity
1354       // type.
1355       if ($entity_type->getBundleEntityType()) {
1356         $normalization[$bundle_field_name] = 'bad_bundle_name';
1357         $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1358
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);
1362       }
1363
1364       unset($normalization[$bundle_field_name]);
1365       $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1366
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);
1370     }
1371   }
1372
1373   /**
1374    * Asserts structure of $patchProtectedFieldNames.
1375    */
1376   protected function assertPatchProtectedFieldNamesStructure() {
1377     $is_null_or_string = function ($value) {
1378       return is_null($value) || is_string($value);
1379     };
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.');
1383   }
1384
1385   /**
1386    * Gets an entity resource's GET/PATCH/DELETE URL.
1387    *
1388    * @return \Drupal\Core\Url
1389    *   The URL to GET/PATCH/DELETE.
1390    */
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());
1397   }
1398
1399   /**
1400    * Gets an entity resource's POST URL.
1401    *
1402    * @return \Drupal\Core\Url
1403    *   The URL to POST to.
1404    */
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);
1408   }
1409
1410   /**
1411    * Clones the given entity and modifies all PATCH-protected fields.
1412    *
1413    * @param \Drupal\Core\Entity\EntityInterface $entity
1414    *   The entity being tested and to modify.
1415    *
1416    * @return array
1417    *   Contains two items:
1418    *   1. The modified entity object.
1419    *   2. The original field values, keyed by field name.
1420    *
1421    * @internal
1422    */
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]);
1435           break;
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';
1440           break;
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)));
1446           break;
1447         default:
1448           $original_field = clone $field;
1449           while ($field->equals($original_field)) {
1450             $field->generateSampleItems();
1451           }
1452           break;
1453       }
1454     }
1455
1456     return [$modified_entity, $original_values];
1457   }
1458
1459   /**
1460    * Makes the given entity normalization invalid.
1461    *
1462    * @param array $normalization
1463    *   An entity normalization.
1464    * @param string $entity_key
1465    *   The entity key whose normalization to make invalid.
1466    *
1467    * @return array
1468    *   The updated entity normalization, now invalid.
1469    */
1470   protected function makeNormalizationInvalid(array $normalization, $entity_key) {
1471     $entity_type = $this->entity->getEntityType();
1472     switch ($entity_key) {
1473       case 'label':
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';
1477         break;
1478       case 'id':
1479         $normalization[$entity_type->getKey('id')][0]['value'] = $this->anotherEntity->id();
1480         break;
1481       case 'uuid':
1482         $normalization[$entity_type->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid();
1483         break;
1484     }
1485     return $normalization;
1486   }
1487
1488   /**
1489    * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1490    *
1491    * Asserting a 406 response should be easy, but it's not, due to bugs.
1492    *
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.)
1501    *
1502    * @todo Remove this in https://www.drupal.org/node/2805279.
1503    *
1504    * @param \Psr\Http\Message\ResponseInterface $response
1505    *   The response to assert.
1506    */
1507   protected function assert406Response(ResponseInterface $response) {
1508     if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1509       $this->assertSame(403, $response->getStatusCode());
1510     }
1511     else {
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'));
1516     }
1517   }
1518
1519   /**
1520    * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1521    *
1522    * @param \Drupal\Core\Url $url
1523    *   URL to request.
1524    * @param array $request_options
1525    *   Request options to apply.
1526    */
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());
1532     }
1533     else {
1534       $this->assert406Response($response);
1535     }
1536   }
1537
1538   /**
1539    * Asserts that the stored entity matches the sent normalization.
1540    *
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.
1545    */
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
1561         // properties.
1562         $this->assertArraySubset($expected_field_normalization, $modified_entity->get($field_name)->getValue(), TRUE);
1563       }
1564     }
1565   }
1566
1567 }