More updates to stop using dev or alpha or beta versions.
[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\Utility\NestedArray;
6 use Drupal\Component\Utility\Random;
7 use Drupal\Core\Cache\Cache;
8 use Drupal\Core\Cache\CacheableResponseInterface;
9 use Drupal\Core\Cache\CacheableMetadata;
10 use Drupal\Core\Config\Entity\ConfigEntityInterface;
11 use Drupal\Core\Entity\ContentEntityNullStorage;
12 use Drupal\Core\Entity\EntityInterface;
13 use Drupal\Core\Entity\FieldableEntityInterface;
14 use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
15 use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
16 use Drupal\Core\Url;
17 use Drupal\field\Entity\FieldConfig;
18 use Drupal\field\Entity\FieldStorageConfig;
19 use Drupal\path\Plugin\Field\FieldType\PathItem;
20 use Drupal\rest\ResourceResponseInterface;
21 use Drupal\Tests\rest\Functional\ResourceTestBase;
22 use GuzzleHttp\RequestOptions;
23 use Psr\Http\Message\ResponseInterface;
24
25 /**
26  * Even though there is the generic EntityResource, it's necessary for every
27  * entity type to have its own test, because they each have different fields,
28  * validation constraints, et cetera. It's not because the generic case works,
29  * that every case works.
30  *
31  * Furthermore, it's necessary to test every format separately, because there
32  * can be entity type-specific normalization or serialization problems.
33  *
34  * Subclass this for every entity type. Also respect instructions in
35  * \Drupal\rest\Tests\ResourceTestBase.
36  *
37  * For example, for the node test coverage, there is the (abstract)
38  * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which
39  * is then again subclassed for every authentication provider:
40  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest
41  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
42  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest
43  * But the HAL module also adds a new format ('hal_json'), so that format also
44  * needs test coverage (for its own peculiarities in normalization & encoding):
45  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest
46  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest
47  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest
48  *
49  * In other words: for every entity type there should be:
50  * 1. an abstract subclass that includes the entity type-specific authorization
51  *    (permissions or perhaps custom access control handling, such as node
52  *    grants), plus
53  * 2. a concrete subclass extending the abstract entity type-specific subclass
54  *    that specifies the exact @code $format @endcode, @code $mimeType @endcode
55  *    and @code $auth @endcode for this concrete test. Usually that's all that's
56  *    necessary: most concrete subclasses will be very thin.
57  *
58  * For every of these concrete subclasses, a comprehensive test scenario will
59  * run per HTTP method:
60  * - ::testGet()
61  * - ::testPost()
62  * - ::testPatch()
63  * - ::testDelete()
64  *
65  * If there is an entity type-specific edge case scenario to test, then add that
66  * to the entity type-specific abstract subclass. Example:
67  * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields
68  *
69  * If there is an entity type-specific format-specific edge case to test, then
70  * add that to a concrete subclass. Example:
71  * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames
72  */
73 abstract class EntityResourceTestBase extends ResourceTestBase {
74
75   /**
76    * The tested entity type.
77    *
78    * @var string
79    */
80   protected static $entityTypeId = NULL;
81
82   /**
83    * The fields that are protected against modification during PATCH requests.
84    *
85    * @var string[]
86    */
87   protected static $patchProtectedFieldNames;
88
89   /**
90    * The fields that need a different (random) value for each new entity created
91    * by a POST request.
92    *
93    * @var string[]
94    */
95   protected static $uniqueFieldNames = [];
96
97   /**
98    * Optionally specify which field is the 'label' field. Some entities specify
99    * a 'label_callback', but not a 'label' entity key. For example: User.
100    *
101    * @see ::getInvalidNormalizedEntityToCreate
102    *
103    * @var string|null
104    */
105   protected static $labelFieldName = NULL;
106
107   /**
108    * The entity ID for the first created entity in testPost().
109    *
110    * The default value of 2 should work for most content entities.
111    *
112    * @see ::testPost()
113    *
114    * @var string|int
115    */
116   protected static $firstCreatedEntityId = 2;
117
118   /**
119    * The entity ID for the second created entity in testPost().
120    *
121    * The default value of 3 should work for most content entities.
122    *
123    * @see ::testPost()
124    *
125    * @var string|int
126    */
127   protected static $secondCreatedEntityId = 3;
128
129   /**
130    * The main entity used for testing.
131    *
132    * @var \Drupal\Core\Entity\EntityInterface
133    */
134   protected $entity;
135
136   /**
137    * Another entity of the same type used for testing.
138    *
139    * @var \Drupal\Core\Entity\EntityInterface
140    */
141   protected $anotherEntity;
142
143   /**
144    * The entity storage.
145    *
146    * @var \Drupal\Core\Entity\EntityStorageInterface
147    */
148   protected $entityStorage;
149
150   /**
151    * Modules to install.
152    *
153    * @var array
154    */
155   public static $modules = ['rest_test', 'text'];
156
157   /**
158    * Provides an entity resource.
159    */
160   protected function provisionEntityResource() {
161     // It's possible to not have any authentication providers enabled, when
162     // testing public (anonymous) usage of a REST resource.
163     $auth = isset(static::$auth) ? [static::$auth] : [];
164     $this->provisionResource([static::$format], $auth);
165   }
166
167   /**
168    * {@inheritdoc}
169    */
170   public function setUp() {
171     parent::setUp();
172
173     // Calculate REST Resource config entity ID.
174     static::$resourceConfigId = 'entity.' . static::$entityTypeId;
175
176     $this->entityStorage = $this->container->get('entity_type.manager')
177       ->getStorage(static::$entityTypeId);
178
179     // Create an entity.
180     $this->entity = $this->createEntity();
181
182     if ($this->entity instanceof FieldableEntityInterface) {
183       // Add access-protected field.
184       FieldStorageConfig::create([
185         'entity_type' => static::$entityTypeId,
186         'field_name' => 'field_rest_test',
187         'type' => 'text',
188       ])
189         ->setCardinality(1)
190         ->save();
191       FieldConfig::create([
192         'entity_type' => static::$entityTypeId,
193         'field_name' => 'field_rest_test',
194         'bundle' => $this->entity->bundle(),
195       ])
196         ->setLabel('Test field')
197         ->setTranslatable(FALSE)
198         ->save();
199
200       // Add multi-value field.
201       FieldStorageConfig::create([
202         'entity_type' => static::$entityTypeId,
203         'field_name' => 'field_rest_test_multivalue',
204         'type' => 'string',
205       ])
206         ->setCardinality(3)
207         ->save();
208       FieldConfig::create([
209         'entity_type' => static::$entityTypeId,
210         'field_name' => 'field_rest_test_multivalue',
211         'bundle' => $this->entity->bundle(),
212       ])
213         ->setLabel('Test field: multi-value')
214         ->setTranslatable(FALSE)
215         ->save();
216
217       // Reload entity so that it has the new field.
218       $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id());
219       // Some entity types are not stored, hence they cannot be reloaded.
220       if ($reloaded_entity !== NULL) {
221         $this->entity = $reloaded_entity;
222
223         // Set a default value on the fields.
224         $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
225         $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]);
226         $this->entity->save();
227       }
228     }
229   }
230
231   /**
232    * Creates the entity to be tested.
233    *
234    * @return \Drupal\Core\Entity\EntityInterface
235    *   The entity to be tested.
236    */
237   abstract protected function createEntity();
238
239   /**
240    * Creates another entity to be tested.
241    *
242    * @return \Drupal\Core\Entity\EntityInterface
243    *   Another entity based on $this->entity.
244    */
245   protected function createAnotherEntity() {
246     $entity = $this->entity->createDuplicate();
247     $label_key = $entity->getEntityType()->getKey('label');
248     if ($label_key) {
249       $entity->set($label_key, $entity->label() . '_dupe');
250     }
251     $entity->save();
252     return $entity;
253   }
254
255   /**
256    * Returns the expected normalization of the entity.
257    *
258    * @see ::createEntity()
259    *
260    * @return array
261    */
262   abstract protected function getExpectedNormalizedEntity();
263
264   /**
265    * Returns the normalized POST entity.
266    *
267    * @see ::testPost
268    *
269    * @return array
270    */
271   abstract protected function getNormalizedPostEntity();
272
273   /**
274    * Returns the normalized PATCH entity.
275    *
276    * By default, reuses ::getNormalizedPostEntity(), which works fine for most
277    * entity types. A counterexample: the 'comment' entity type.
278    *
279    * @see ::testPatch
280    *
281    * @return array
282    */
283   protected function getNormalizedPatchEntity() {
284     return $this->getNormalizedPostEntity();
285   }
286
287   /**
288    * Gets the second normalized POST entity.
289    *
290    * Entity types can have non-sequential IDs, and in that case the second
291    * entity created for POST testing needs to be able to specify a different ID.
292    *
293    * @see ::testPost
294    * @see ::getNormalizedPostEntity
295    *
296    * @return array
297    *   An array structure as returned by ::getNormalizedPostEntity().
298    */
299   protected function getSecondNormalizedPostEntity() {
300     // Return the values of the "parent" method by default.
301     return $this->getNormalizedPostEntity();
302   }
303
304   /**
305    * Gets the normalized POST entity with random values for its unique fields.
306    *
307    * @see ::testPost
308    * @see ::getNormalizedPostEntity
309    *
310    * @return array
311    *   An array structure as returned by ::getNormalizedPostEntity().
312    */
313   protected function getModifiedEntityForPostTesting() {
314     $normalized_entity = $this->getNormalizedPostEntity();
315
316     // Ensure that all the unique fields of the entity type get a new random
317     // value.
318     foreach (static::$uniqueFieldNames as $field_name) {
319       $field_definition = $this->entity->getFieldDefinition($field_name);
320       $field_type_class = $field_definition->getItemDefinition()->getClass();
321       $normalized_entity[$field_name] = $field_type_class::generateSampleValue($field_definition);
322     }
323
324     return $normalized_entity;
325   }
326
327   /**
328    * {@inheritdoc}
329    */
330   protected function getExpectedUnauthorizedAccessMessage($method) {
331
332     if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
333       return parent::getExpectedUnauthorizedAccessMessage($method);
334     }
335
336     $permission = $this->entity->getEntityType()->getAdminPermission();
337     if ($permission !== FALSE) {
338       return "The '{$permission}' permission is required.";
339     }
340
341     $http_method_to_entity_operation = [
342       'GET' => 'view',
343       'POST' => 'create',
344       'PATCH' => 'update',
345       'DELETE' => 'delete',
346     ];
347     $operation = $http_method_to_entity_operation[$method];
348     $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
349
350     if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
351       $message .= ' of bundle ' . $this->entity->bundle();
352     }
353
354     return "$message.";
355   }
356
357   /**
358    * {@inheritdoc}
359    */
360   protected function getExpectedUnauthorizedAccessCacheability() {
361     return (new CacheableMetadata())
362       ->setCacheTags(static::$auth
363         ? ['4xx-response', 'http_response']
364         : ['4xx-response', 'config:user.role.anonymous', 'http_response'])
365       ->setCacheContexts(['user.permissions']);
366   }
367
368   /**
369    * The expected cache tags for the GET/HEAD response of the test entity.
370    *
371    * @see ::testGet
372    *
373    * @return string[]
374    */
375   protected function getExpectedCacheTags() {
376     $expected_cache_tags = [
377       'config:rest.resource.entity.' . static::$entityTypeId,
378       // Necessary for 'bc_entity_resource_permissions'.
379       // @see \Drupal\rest\Plugin\rest\resource\EntityResource::permissions()
380       'config:rest.settings',
381     ];
382     if (!static::$auth) {
383       $expected_cache_tags[] = 'config:user.role.anonymous';
384     }
385     $expected_cache_tags[] = 'http_response';
386     return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
387   }
388
389   /**
390    * The expected cache contexts for the GET/HEAD response of the test entity.
391    *
392    * @see ::testGet
393    *
394    * @return string[]
395    */
396   protected function getExpectedCacheContexts() {
397     return [
398       'url.site',
399       'user.permissions',
400     ];
401   }
402
403   /**
404    * Test a GET request for an entity, plus edge cases to ensure good DX.
405    */
406   public function testGet() {
407     $this->initAuthentication();
408     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
409
410     // The URL and Guzzle request options that will be used in this test. The
411     // request options will be modified/expanded throughout this test:
412     // - to first test all mistakes a developer might make, and assert that the
413     //   error responses provide a good DX
414     // - to eventually result in a well-formed request that succeeds.
415     $url = $this->getEntityResourceUrl();
416     $request_options = [];
417
418     // DX: 404 when resource not provisioned, 403 if canonical route. HTML
419     // response because missing ?_format query string.
420     $response = $this->request('GET', $url, $request_options);
421     $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
422     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
423
424     $url->setOption('query', ['_format' => static::$format]);
425
426     // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
427     // response because ?_format query string is present.
428     $response = $this->request('GET', $url, $request_options);
429     if ($has_canonical_url) {
430       $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
431     }
432     else {
433       $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
434     }
435
436     $this->provisionEntityResource();
437     // Simulate the developer again forgetting the ?_format query string.
438     $url->setOption('query', []);
439
440     // DX: 406 when ?_format is missing, except when requesting a canonical HTML
441     // route.
442     $response = $this->request('GET', $url, $request_options);
443     if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
444       $this->assertSame(403, $response->getStatusCode());
445     }
446     else {
447       $this->assert406Response($response);
448     }
449
450     $url->setOption('query', ['_format' => static::$format]);
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     // DX: 403 when unauthorized.
476     $response = $this->request('GET', $url, $request_options);
477     $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability();
478     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
479     $this->assertArrayNotHasKey('Link', $response->getHeaders());
480
481     $this->setUpAuthorization('GET');
482
483     // 200 for well-formed HEAD request.
484     $response = $this->request('HEAD', $url, $request_options);
485     $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
486     $head_headers = $response->getHeaders();
487
488     // 200 for well-formed GET request. Page Cache hit because of HEAD request.
489     // Same for Dynamic Page Cache hit.
490     $response = $this->request('GET', $url, $request_options);
491     $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', static::$auth ? 'HIT' : 'MISS');
492     // Assert that Dynamic Page Cache did not store a ResourceResponse object,
493     // which needs serialization after every cache hit. Instead, it should
494     // contain a flattened response. Otherwise performance suffers.
495     // @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
496     $cache_items = $this->container->get('database')
497       ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [
498         ':pattern' => '%[route]=rest.%',
499       ])
500       ->fetchAllAssoc('cid');
501     $this->assertTrue(count($cache_items) >= 2);
502     $found_cache_redirect = FALSE;
503     $found_cached_200_response = FALSE;
504     $other_cached_responses_are_4xx = TRUE;
505     foreach ($cache_items as $cid => $cache_item) {
506       $cached_data = unserialize($cache_item->data);
507       if (!isset($cached_data['#cache_redirect'])) {
508         $cached_response = $cached_data['#response'];
509         if ($cached_response->getStatusCode() === 200) {
510           $found_cached_200_response = TRUE;
511         }
512         elseif (!$cached_response->isClientError()) {
513           $other_cached_responses_are_4xx = FALSE;
514         }
515         $this->assertNotInstanceOf(ResourceResponseInterface::class, $cached_response);
516         $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
517       }
518       else {
519         $found_cache_redirect = TRUE;
520       }
521     }
522     $this->assertTrue($found_cache_redirect);
523     $this->assertTrue($found_cached_200_response);
524     $this->assertTrue($other_cached_responses_are_4xx);
525
526     // Sort the serialization data first so we can do an identical comparison
527     // for the keys with the array order the same (it needs to match with
528     // identical comparison).
529     $expected = $this->getExpectedNormalizedEntity();
530     static::recursiveKSort($expected);
531     $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
532     static::recursiveKSort($actual);
533     $this->assertSame($expected, $actual);
534
535     // Not only assert the normalization, also assert deserialization of the
536     // response results in the expected object.
537     // Note: deserialization of the XML format is not supported, so only test
538     // this for other formats.
539     if (static::$format !== 'xml') {
540       // @todo Work-around for HAL's FileEntityNormalizer::denormalize() being
541       // broken, being fixed in https://www.drupal.org/node/1927648, where this
542       // if-test should be removed.
543       if (!(static::$entityTypeId === 'file' && static::$format === 'hal_json')) {
544         $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
545         $this->assertSame($unserialized->uuid(), $this->entity->uuid());
546       }
547     }
548     // Finally, assert that the expected 'Link' headers are present.
549     if ($this->entity->getEntityType()->getLinkTemplates()) {
550       $this->assertArrayHasKey('Link', $response->getHeaders());
551       $link_relation_type_manager = $this->container->get('plugin.manager.link_relation_type');
552       $expected_link_relation_headers = array_map(function ($relation_name) use ($link_relation_type_manager) {
553         $link_relation_type = $link_relation_type_manager->createInstance($relation_name);
554         return $link_relation_type->isRegistered()
555           ? $link_relation_type->getRegisteredName()
556           : $link_relation_type->getExtensionUri();
557       }, array_keys($this->entity->getEntityType()->getLinkTemplates()));
558       $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
559         $matches = [];
560         if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
561           return $matches[1];
562         }
563         return FALSE;
564       };
565       $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
566     }
567     $get_headers = $response->getHeaders();
568
569     // Verify that the GET and HEAD responses are the same. The only difference
570     // is that there's no body. For this reason the 'Transfer-Encoding' and
571     // 'Vary' headers are also added to the list of headers to ignore, as they
572     // may be added to GET requests, depending on web server configuration. They
573     // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
574     $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary'];
575     $header_cleaner = function ($headers) use ($ignored_headers) {
576       foreach ($headers as $header => $value) {
577         if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
578           unset($headers[$header]);
579         }
580       }
581       return $headers;
582     };
583     $get_headers = $header_cleaner($get_headers);
584     $head_headers = $header_cleaner($head_headers);
585     $this->assertSame($get_headers, $head_headers);
586
587     // BC: serialization_update_8302().
588     // Only run this for fieldable entities. It doesn't make sense for config
589     // entities as config values are already casted. They also run through the
590     // ConfigEntityNormalizer, which doesn't deal with fields individually.
591     if ($this->entity instanceof FieldableEntityInterface) {
592       // Test primitive data casting BC (strings).
593       $this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
594       // Rebuild the container so new config is reflected in the addition of the
595       // PrimitiveDataNormalizer.
596       $this->rebuildAll();
597
598       $response = $this->request('GET', $url, $request_options);
599       $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
600
601       // Again do an identical comparison, but this time transform the expected
602       // normalized entity's values to strings. This ensures the BC layer for
603       // bc_primitives_as_strings works as expected.
604       $expected = $this->getExpectedNormalizedEntity();
605       // Config entities are not affected.
606       // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
607       $expected = static::castToString($expected);
608       static::recursiveKSort($expected);
609       $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
610       static::recursiveKSort($actual);
611       $this->assertSame($expected, $actual);
612
613       // Reset the config value and rebuild.
614       $this->config('serialization.settings')->set('bc_primitives_as_strings', FALSE)->save(TRUE);
615       $this->rebuildAll();
616     }
617
618     // BC: serialization_update_8401().
619     // Only run this for fieldable entities. It doesn't make sense for config
620     // entities as config values always use the raw values (as per the config
621     // schema), returned directly from the ConfigEntityNormalizer, which
622     // doesn't deal with fields individually.
623     if ($this->entity instanceof FieldableEntityInterface) {
624       // Test the BC settings for timestamp values.
625       $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE);
626       // Rebuild the container so new config is reflected in the addition of the
627       // TimestampItemNormalizer.
628       $this->rebuildAll();
629
630       $response = $this->request('GET', $url, $request_options);
631       $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');
632
633       // This ensures the BC layer for bc_timestamp_normalizer_unix works as
634       // expected. This method should be using
635       // ::formatExpectedTimestampValue() to generate the timestamp value. This
636       // will take into account the above config setting.
637       $expected = $this->getExpectedNormalizedEntity();
638       // Config entities are not affected.
639       // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
640       static::recursiveKSort($expected);
641       $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
642       static::recursiveKSort($actual);
643       $this->assertSame($expected, $actual);
644
645       // Reset the config value and rebuild.
646       $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE);
647       $this->rebuildAll();
648     }
649
650     // BC: rest_update_8203().
651     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
652     $this->refreshTestStateAfterRestConfigChange();
653
654     // DX: 403 when unauthorized.
655     $response = $this->request('GET', $url, $request_options);
656     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
657
658     $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
659
660     // 200 for well-formed request.
661     $response = $this->request('GET', $url, $request_options);
662     $expected_cache_tags = $this->getExpectedCacheTags();
663     $expected_cache_contexts = $this->getExpectedCacheContexts();
664     // @todo Fix BlockAccessControlHandler::mergeCacheabilityFromConditions() in
665     //   https://www.drupal.org/node/2867881
666     if (static::$entityTypeId === 'block') {
667       $expected_cache_contexts = Cache::mergeContexts($expected_cache_contexts, ['user.permissions']);
668     }
669     // \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies to
670     // cacheable anonymous responses: it updates their cacheability. Therefore
671     // we must update our cacheability expectations for anonymous responses
672     // accordingly.
673     if (!static::$auth && in_array('user.permissions', $expected_cache_contexts, TRUE)) {
674       $expected_cache_tags = Cache::mergeTags($expected_cache_tags, ['config:user.role.anonymous']);
675     }
676     $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
677
678     $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
679     $this->refreshTestStateAfterRestConfigChange();
680
681     // DX: upon disabling a resource, it's immediately no longer available.
682     $this->assertResourceNotAvailable($url, $request_options);
683
684     $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
685     $this->refreshTestStateAfterRestConfigChange();
686
687     // DX: upon re-enabling a resource, immediate 200.
688     $response = $this->request('GET', $url, $request_options);
689     $this->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', 'MISS');
690
691     $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
692     $this->refreshTestStateAfterRestConfigChange();
693
694     // DX: upon deleting a resource, it's immediately no longer available.
695     $this->assertResourceNotAvailable($url, $request_options);
696
697     $this->provisionEntityResource();
698     $url->setOption('query', ['_format' => 'non_existing_format']);
699
700     // DX: 406 when requesting unsupported format.
701     $response = $this->request('GET', $url, $request_options);
702     $this->assert406Response($response);
703     $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
704
705     $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
706
707     // DX: 406 when requesting unsupported format but specifying Accept header:
708     // should result in a text/plain response.
709     $response = $this->request('GET', $url, $request_options);
710     $this->assert406Response($response);
711     $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
712
713     $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET');
714     $url->setRouteParameter(static::$entityTypeId, 987654321);
715     $url->setOption('query', ['_format' => static::$format]);
716
717     // DX: 404 when GETting non-existing entity.
718     $response = $this->request('GET', $url, $request_options);
719     $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
720     $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET")';
721     $this->assertResourceErrorResponse(404, $message, $response);
722   }
723
724   /**
725    * Transforms a normalization: casts all non-string types to strings.
726    *
727    * @param array $normalization
728    *   A normalization to transform.
729    *
730    * @return array
731    *   The transformed normalization.
732    */
733   protected static function castToString(array $normalization) {
734     foreach ($normalization as $key => $value) {
735       if (is_bool($value)) {
736         $normalization[$key] = (string) (int) $value;
737       }
738       elseif (is_int($value) || is_float($value)) {
739         $normalization[$key] = (string) $value;
740       }
741       elseif (is_array($value)) {
742         $normalization[$key] = static::castToString($value);
743       }
744     }
745     return $normalization;
746   }
747
748   /**
749    * Recursively sorts an array by key.
750    *
751    * @param array $array
752    *   An array to sort.
753    *
754    * @return array
755    *   The sorted array.
756    */
757   protected static function recursiveKSort(array &$array) {
758     // First, sort the main array.
759     ksort($array);
760
761     // Then check for child arrays.
762     foreach ($array as $key => &$value) {
763       if (is_array($value)) {
764         static::recursiveKSort($value);
765       }
766     }
767   }
768
769   /**
770    * Tests a POST request for an entity, plus edge cases to ensure good DX.
771    */
772   public function testPost() {
773     // @todo Remove this in https://www.drupal.org/node/2300677.
774     if ($this->entity instanceof ConfigEntityInterface) {
775       $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
776       return;
777     }
778
779     $this->initAuthentication();
780     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
781
782     // Try with all of the following request bodies.
783     $unparseable_request_body = '!{>}<';
784     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
785     $parseable_valid_request_body_2 = $this->serializer->encode($this->getSecondNormalizedPostEntity(), static::$format);
786     $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity(), 'label'), static::$format);
787     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format);
788     $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
789
790     // The URL and Guzzle request options that will be used in this test. The
791     // request options will be modified/expanded throughout this test:
792     // - to first test all mistakes a developer might make, and assert that the
793     //   error responses provide a good DX
794     // - to eventually result in a well-formed request that succeeds.
795     $url = $this->getEntityResourcePostUrl();
796     $request_options = [];
797
798     // DX: 404 when resource not provisioned. HTML response because missing
799     // ?_format query string.
800     $response = $this->request('POST', $url, $request_options);
801     $this->assertSame(404, $response->getStatusCode());
802     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
803
804     $url->setOption('query', ['_format' => static::$format]);
805
806     // DX: 404 when resource not provisioned.
807     $response = $this->request('POST', $url, $request_options);
808     $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getEntityResourcePostUrl()->setAbsolute()->toString()) . '"', $response);
809
810     $this->provisionEntityResource();
811     // Simulate the developer again forgetting the ?_format query string.
812     $url->setOption('query', []);
813
814     // DX: 415 when no Content-Type request header. HTML response because
815     // missing ?_format query string.
816     $response = $this->request('POST', $url, $request_options);
817     $this->assertSame(415, $response->getStatusCode());
818     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
819     $this->assertContains('A client error happened', (string) $response->getBody());
820
821     $url->setOption('query', ['_format' => static::$format]);
822
823     // DX: 415 when no Content-Type request header.
824     $response = $this->request('POST', $url, $request_options);
825     $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
826
827     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
828
829     // DX: 400 when no request body.
830     $response = $this->request('POST', $url, $request_options);
831     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
832
833     $request_options[RequestOptions::BODY] = $unparseable_request_body;
834
835     // DX: 400 when unparseable request body.
836     $response = $this->request('POST', $url, $request_options);
837     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
838
839     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
840
841     if (static::$auth) {
842       // DX: forgetting authentication: authentication provider-specific error
843       // response.
844       $response = $this->request('POST', $url, $request_options);
845       $this->assertResponseWhenMissingAuthentication('POST', $response);
846     }
847
848     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
849
850     // DX: 403 when unauthorized.
851     $response = $this->request('POST', $url, $request_options);
852     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
853
854     $this->setUpAuthorization('POST');
855
856     // DX: 422 when invalid entity: multiple values sent for single-value field.
857     $response = $this->request('POST', $url, $request_options);
858     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
859     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
860     $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
861
862     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
863
864     // DX: 422 when invalid entity: UUID field too long.
865     // @todo Fix this in https://www.drupal.org/node/2149851.
866     if ($this->entity->getEntityType()->hasKey('uuid')) {
867       $response = $this->request('POST', $url, $request_options);
868       $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
869     }
870
871     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
872
873     // DX: 403 when entity contains field without 'edit' access.
874     $response = $this->request('POST', $url, $request_options);
875     $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
876
877     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
878
879     // Before sending a well-formed request, allow the normalization and
880     // authentication provider edge cases to also be tested.
881     $this->assertNormalizationEdgeCases('POST', $url, $request_options);
882     $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
883
884     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
885
886     // DX: 415 when request body in existing but not allowed format.
887     $response = $this->request('POST', $url, $request_options);
888     $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
889
890     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
891
892     // 201 for well-formed request.
893     $response = $this->request('POST', $url, $request_options);
894     $this->assertResourceResponse(201, FALSE, $response);
895     if ($has_canonical_url) {
896       $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
897       $this->assertSame([$location], $response->getHeader('Location'));
898     }
899     else {
900       $this->assertSame([], $response->getHeader('Location'));
901     }
902     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
903     // If the entity is stored, perform extra checks.
904     if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
905       // Assert that the entity was indeed created, and that the response body
906       // contains the serialized created entity.
907       $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
908       $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
909       // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute
910       // its body unconditionally.
911       if (static::$entityTypeId !== 'taxonomy_term') {
912         $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
913       }
914       // Assert that the entity was indeed created using the POSTed values.
915       foreach ($this->getNormalizedPostEntity() as $field_name => $field_normalization) {
916         // Some top-level keys in the normalization may not be fields on the
917         // entity (for example '_links' and '_embedded' in the HAL normalization).
918         if ($created_entity->hasField($field_name)) {
919           // Subset, not same, because we can e.g. send just the target_id for the
920           // bundle in a POST request; the response will include more properties.
921           $this->assertArraySubset(static::castToString($field_normalization), $created_entity->get($field_name)
922             ->getValue(), TRUE);
923         }
924       }
925     }
926
927     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
928     $this->refreshTestStateAfterRestConfigChange();
929     $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
930
931     // DX: 403 when unauthorized.
932     $response = $this->request('POST', $url, $request_options);
933     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
934
935     $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
936
937     // 201 for well-formed request.
938     // If the entity is stored, delete the first created entity (in case there
939     // is a uniqueness constraint).
940     if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
941       $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
942     }
943     $response = $this->request('POST', $url, $request_options);
944     $this->assertResourceResponse(201, FALSE, $response);
945     $created_entity = $this->entityStorage->load(static::$secondCreatedEntityId);
946     if ($has_canonical_url) {
947       $location = $created_entity->toUrl('canonical')->setAbsolute(TRUE)->toString();
948       $this->assertSame([$location], $response->getHeader('Location'));
949     }
950     else {
951       $this->assertSame([], $response->getHeader('Location'));
952     }
953     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
954
955     if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) {
956       // 500 when creating an entity with a duplicate UUID.
957       $normalized_entity = $this->getModifiedEntityForPostTesting();
958       $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $created_entity->uuid()]];
959       $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
960       $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
961
962       $response = $this->request('POST', $url, $request_options);
963       $this->assertSame(500, $response->getStatusCode());
964       $this->assertContains('Internal Server Error', (string) $response->getBody());
965
966       // 201 when successfully creating an entity with a new UUID.
967       $normalized_entity = $this->getModifiedEntityForPostTesting();
968       $new_uuid = \Drupal::service('uuid')->generate();
969       $normalized_entity[$created_entity->getEntityType()->getKey('uuid')] = [['value' => $new_uuid]];
970       $normalized_entity[$label_field] = [['value' => $this->randomMachineName()]];
971       $request_options[RequestOptions::BODY] = $this->serializer->encode($normalized_entity, static::$format);
972
973       $response = $this->request('POST', $url, $request_options);
974       $this->assertResourceResponse(201, FALSE, $response);
975       $entities = $this->entityStorage->loadByProperties([$created_entity->getEntityType()->getKey('uuid') => $new_uuid]);
976       $new_entity = reset($entities);
977       $this->assertNotNull($new_entity);
978       $new_entity->delete();
979     }
980
981     // BC: old default POST URLs have their path updated by the inbound path
982     // processor \Drupal\rest\PathProcessor\PathProcessorEntityResourceBC to the
983     // new URL, which is derived from the 'create' link template if an entity
984     // type specifies it.
985     if ($this->entity->getEntityType()->hasLinkTemplate('create')) {
986       $this->entityStorage->load(static::$secondCreatedEntityId)->delete();
987       $old_url = Url::fromUri('base:entity/' . static::$entityTypeId);
988       $old_url->setOption('query', ['_format' => static::$format]);
989       $response = $this->request('POST', $old_url, $request_options);
990       $this->assertResourceResponse(201, FALSE, $response);
991     }
992   }
993
994   /**
995    * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
996    */
997   public function testPatch() {
998     // @todo Remove this in https://www.drupal.org/node/2300677.
999     if ($this->entity instanceof ConfigEntityInterface) {
1000       $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
1001       return;
1002     }
1003
1004     // Patch testing requires that another entity of the same type exists.
1005     $this->anotherEntity = $this->createAnotherEntity();
1006
1007     $this->initAuthentication();
1008     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1009
1010     // Try with all of the following request bodies.
1011     $unparseable_request_body = '!{>}<';
1012     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1013     $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
1014     $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'label'), static::$format);
1015     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
1016     // The 'field_rest_test' field does not allow 'view' access, so does not end
1017     // up in the normalization. Even when we explicitly add it the normalization
1018     // that we send in the body of a PATCH request, it is considered invalid.
1019     $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], static::$format);
1020
1021     // The URL and Guzzle request options that will be used in this test. The
1022     // request options will be modified/expanded throughout this test:
1023     // - to first test all mistakes a developer might make, and assert that the
1024     //   error responses provide a good DX
1025     // - to eventually result in a well-formed request that succeeds.
1026     $url = $this->getEntityResourceUrl();
1027     $request_options = [];
1028
1029     // DX: 404 when resource not provisioned, 405 if canonical route. Plain text
1030     // or HTML response because missing ?_format query string.
1031     $response = $this->request('PATCH', $url, $request_options);
1032     if ($has_canonical_url) {
1033       $this->assertSame(405, $response->getStatusCode());
1034       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1035       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1036       $this->assertContains('A client error happened', (string) $response->getBody());
1037     }
1038     else {
1039       $this->assertSame(404, $response->getStatusCode());
1040       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1041     }
1042
1043     $url->setOption('query', ['_format' => static::$format]);
1044
1045     // DX: 404 when resource not provisioned, 405 if canonical route.
1046     $response = $this->request('PATCH', $url, $request_options);
1047     if ($has_canonical_url) {
1048       $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1049     }
1050     else {
1051       $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1052     }
1053
1054     $this->provisionEntityResource();
1055     // Simulate the developer again forgetting the ?_format query string.
1056     $url->setOption('query', []);
1057
1058     // DX: 415 when no Content-Type request header.
1059     $response = $this->request('PATCH', $url, $request_options);
1060     $this->assertSame(415, $response->getStatusCode());
1061     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1062     $this->assertContains('A client error happened', (string) $response->getBody());
1063
1064     $url->setOption('query', ['_format' => static::$format]);
1065
1066     // DX: 415 when no Content-Type request header.
1067     $response = $this->request('PATCH', $url, $request_options);
1068     $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
1069
1070     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1071
1072     // DX: 400 when no request body.
1073     $response = $this->request('PATCH', $url, $request_options);
1074     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
1075
1076     $request_options[RequestOptions::BODY] = $unparseable_request_body;
1077
1078     // DX: 400 when unparseable request body.
1079     $response = $this->request('PATCH', $url, $request_options);
1080     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
1081
1082     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
1083
1084     if (static::$auth) {
1085       // DX: forgetting authentication: authentication provider-specific error
1086       // response.
1087       $response = $this->request('PATCH', $url, $request_options);
1088       $this->assertResponseWhenMissingAuthentication('PATCH', $response);
1089     }
1090
1091     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1092
1093     // DX: 403 when unauthorized.
1094     $response = $this->request('PATCH', $url, $request_options);
1095     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1096
1097     $this->setUpAuthorization('PATCH');
1098
1099     // DX: 422 when invalid entity: multiple values sent for single-value field.
1100     $response = $this->request('PATCH', $url, $request_options);
1101     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
1102     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
1103     $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
1104
1105     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
1106
1107     // DX: 403 when entity contains field without 'edit' access.
1108     $response = $this->request('PATCH', $url, $request_options);
1109     $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1110
1111     // DX: 403 when entity trying to update an entity's ID field.
1112     $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'id'), static::$format);;
1113     $response = $this->request('PATCH', $url, $request_options);
1114     $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'.", $response);
1115
1116     if ($this->entity->getEntityType()->hasKey('uuid')) {
1117       // DX: 403 when entity trying to update an entity's UUID field.
1118       $request_options[RequestOptions::BODY] = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity(), 'uuid'), static::$format);;
1119       $response = $this->request('PATCH', $url, $request_options);
1120       $this->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'.", $response);
1121     }
1122
1123     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
1124
1125     // DX: 403 when entity contains field without 'edit' nor 'view' access, even
1126     // when the value for that field matches the current value. This is allowed
1127     // in principle, but leads to information disclosure.
1128     $response = $this->request('PATCH', $url, $request_options);
1129     $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
1130
1131     // DX: 403 when sending PATCH request with updated read-only fields.
1132     list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);
1133     // Send PATCH request by serializing the modified entity, assert the error
1134     // response, change the modified entity field that caused the error response
1135     // back to its original value, repeat.
1136     for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
1137       $patch_protected_field_name = static::$patchProtectedFieldNames[$i];
1138       $request_options[RequestOptions::BODY] = $this->serializer->serialize($modified_entity, static::$format);
1139       $response = $this->request('PATCH', $url, $request_options);
1140       $this->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'.", $response);
1141       $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]);
1142     }
1143
1144     // 200 for well-formed PATCH request that sends all fields (even including
1145     // read-only ones, but with unchanged values).
1146     $valid_request_body = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
1147     $request_options[RequestOptions::BODY] = $this->serializer->serialize($valid_request_body, static::$format);
1148     $response = $this->request('PATCH', $url, $request_options);
1149     $this->assertResourceResponse(200, FALSE, $response);
1150
1151     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
1152
1153     // Before sending a well-formed request, allow the normalization and
1154     // authentication provider edge cases to also be tested.
1155     $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
1156     $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
1157
1158     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
1159
1160     // DX: 415 when request body in existing but not allowed format.
1161     $response = $this->request('PATCH', $url, $request_options);
1162     $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
1163
1164     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
1165
1166     // 200 for well-formed request.
1167     $response = $this->request('PATCH', $url, $request_options);
1168     $this->assertResourceResponse(200, FALSE, $response);
1169     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1170     // Assert that the entity was indeed updated, and that the response body
1171     // contains the serialized updated entity.
1172     $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
1173     $updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]);
1174     $this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
1175     // Assert that the entity was indeed created using the PATCHed values.
1176     foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) {
1177       // Some top-level keys in the normalization may not be fields on the
1178       // entity (for example '_links' and '_embedded' in the HAL normalization).
1179       if ($updated_entity->hasField($field_name)) {
1180         // Subset, not same, because we can e.g. send just the target_id for the
1181         // bundle in a PATCH request; the response will include more properties.
1182         $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
1183       }
1184     }
1185     // Ensure that fields do not get deleted if they're not present in the PATCH
1186     // request. Test this using the configurable field that we added, but which
1187     // is not sent in the PATCH request.
1188     $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value);
1189
1190     // Multi-value field: remove item 0. Then item 1 becomes item 0.
1191     $normalization_multi_value_tests = $this->getNormalizedPatchEntity();
1192     $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue();
1193     $normalization_remove_item = $normalization_multi_value_tests;
1194     unset($normalization_remove_item['field_rest_test_multivalue'][0]);
1195     $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format);
1196     $response = $this->request('PATCH', $url, $request_options);
1197     $this->assertResourceResponse(200, FALSE, $response);
1198     $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1199
1200     // Multi-value field: add one item before the existing one, and one after.
1201     $normalization_add_items = $normalization_multi_value_tests;
1202     $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three'];
1203     $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format);
1204     $response = $this->request('PATCH', $url, $request_options);
1205     $this->assertResourceResponse(200, FALSE, $response);
1206     $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue());
1207
1208     // BC: rest_update_8203().
1209     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1210     $this->refreshTestStateAfterRestConfigChange();
1211     $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
1212
1213     // DX: 403 when unauthorized.
1214     $response = $this->request('PATCH', $url, $request_options);
1215     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
1216
1217     $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
1218
1219     // 200 for well-formed request.
1220     $response = $this->request('PATCH', $url, $request_options);
1221     $this->assertResourceResponse(200, FALSE, $response);
1222     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1223   }
1224
1225   /**
1226    * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
1227    */
1228   public function testDelete() {
1229     // @todo Remove this in https://www.drupal.org/node/2300677.
1230     if ($this->entity instanceof ConfigEntityInterface) {
1231       $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
1232       return;
1233     }
1234
1235     $this->initAuthentication();
1236     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1237
1238     // The URL and Guzzle request options that will be used in this test. The
1239     // request options will be modified/expanded throughout this test:
1240     // - to first test all mistakes a developer might make, and assert that the
1241     //   error responses provide a good DX
1242     // - to eventually result in a well-formed request that succeeds.
1243     $url = $this->getEntityResourceUrl();
1244     $request_options = [];
1245
1246     // DX: 404 when resource not provisioned, but 405 if canonical route. Plain
1247     // text  or HTML response because missing ?_format query string.
1248     $response = $this->request('DELETE', $url, $request_options);
1249     if ($has_canonical_url) {
1250       $this->assertSame(405, $response->getStatusCode());
1251       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1252       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1253       $this->assertContains('A client error happened', (string) $response->getBody());
1254     }
1255     else {
1256       $this->assertSame(404, $response->getStatusCode());
1257       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1258     }
1259
1260     $url->setOption('query', ['_format' => static::$format]);
1261
1262     // DX: 404 when resource not provisioned, 405 if canonical route.
1263     $response = $this->request('DELETE', $url, $request_options);
1264     if ($has_canonical_url) {
1265       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1266       $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1267     }
1268     else {
1269       $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1270     }
1271
1272     $this->provisionEntityResource();
1273
1274     if (static::$auth) {
1275       // DX: forgetting authentication: authentication provider-specific error
1276       // response.
1277       $response = $this->request('DELETE', $url, $request_options);
1278       $this->assertResponseWhenMissingAuthentication('DELETE', $response);
1279     }
1280
1281     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1282
1283     // DX: 403 when unauthorized.
1284     $response = $this->request('DELETE', $url, $request_options);
1285     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1286
1287     $this->setUpAuthorization('DELETE');
1288
1289     // Before sending a well-formed request, allow the authentication provider's
1290     // edge cases to also be tested.
1291     $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
1292
1293     // 204 for well-formed request.
1294     $response = $this->request('DELETE', $url, $request_options);
1295     $this->assertResourceResponse(204, '', $response);
1296
1297     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1298     $this->refreshTestStateAfterRestConfigChange();
1299     $this->entity = $this->createEntity();
1300     $url = $this->getEntityResourceUrl()->setOption('query', $url->getOption('query'));
1301
1302     // DX: 403 when unauthorized.
1303     $response = $this->request('DELETE', $url, $request_options);
1304     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1305
1306     $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
1307
1308     // 204 for well-formed request.
1309     $response = $this->request('DELETE', $url, $request_options);
1310     $this->assertResourceResponse(204, '', $response);
1311   }
1312
1313   /**
1314    * {@inheritdoc}
1315    */
1316   protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
1317     // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
1318     // types with bundles MUST send their bundle field to be denormalizable.
1319     $entity_type = $this->entity->getEntityType();
1320     if ($entity_type->hasKey('bundle')) {
1321       $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
1322       $normalization = $this->getNormalizedPostEntity();
1323
1324       // The bundle type itself can be validated only if there's a bundle entity
1325       // type.
1326       if ($entity_type->getBundleEntityType()) {
1327         $normalization[$bundle_field_name] = 'bad_bundle_name';
1328         $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1329
1330         // DX: 422 when incorrect entity type bundle is specified.
1331         $response = $this->request($method, $url, $request_options);
1332         $this->assertResourceErrorResponse(422, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
1333       }
1334
1335       unset($normalization[$bundle_field_name]);
1336       $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1337
1338       // DX: 422 when no entity type bundle is specified.
1339       $response = $this->request($method, $url, $request_options);
1340       $this->assertResourceErrorResponse(422, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response);
1341     }
1342   }
1343
1344   /**
1345    * Gets an entity resource's GET/PATCH/DELETE URL.
1346    *
1347    * @return \Drupal\Core\Url
1348    *   The URL to GET/PATCH/DELETE.
1349    */
1350   protected function getEntityResourceUrl() {
1351     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1352     // Note that the 'canonical' link relation type must be specified explicitly
1353     // in the call to ::toUrl(). 'canonical' is the default for
1354     // \Drupal\Core\Entity\Entity::toUrl(), but ConfigEntityBase overrides this.
1355     return $has_canonical_url ? $this->entity->toUrl('canonical') : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
1356   }
1357
1358   /**
1359    * Gets an entity resource's POST URL.
1360    *
1361    * @return \Drupal\Core\Url
1362    *   The URL to POST to.
1363    */
1364   protected function getEntityResourcePostUrl() {
1365     $has_create_url = $this->entity->hasLinkTemplate('create');
1366     return $has_create_url ? Url::fromUri('internal:' . $this->entity->getEntityType()->getLinkTemplate('create')) : Url::fromUri('base:entity/' . static::$entityTypeId);
1367   }
1368
1369   /**
1370    * Clones the given entity and modifies all PATCH-protected fields.
1371    *
1372    * @param \Drupal\Core\Entity\EntityInterface $entity
1373    *   The entity being tested and to modify.
1374    *
1375    * @return array
1376    *   Contains two items:
1377    *   1. The modified entity object.
1378    *   2. The original field values, keyed by field name.
1379    *
1380    * @internal
1381    */
1382   protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) {
1383     $modified_entity = clone $entity;
1384     $original_values = [];
1385     foreach (static::$patchProtectedFieldNames as $field_name) {
1386       $field = $modified_entity->get($field_name);
1387       $original_values[$field_name] = $field->getValue();
1388       switch ($field->getItemDefinition()->getClass()) {
1389         case EntityReferenceItem::class:
1390           // EntityReferenceItem::generateSampleValue() picks one of the last 50
1391           // entities of the supported type & bundle. We don't care if the value
1392           // is valid, we only care that it's different.
1393           $field->setValue(['target_id' => 99999]);
1394           break;
1395         case BooleanItem::class:
1396           // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50%
1397           // chance of not picking a different value.
1398           $field->value = ((int) $field->value) === 1 ? '0' : '1';
1399           break;
1400         case PathItem::class:
1401           // PathItem::generateSampleValue() doesn't set a PID, which causes
1402           // PathItem::postSave() to fail. Keep the PID (and other properties),
1403           // just modify the alias.
1404           $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3)));
1405           break;
1406         default:
1407           $original_field = clone $field;
1408           while ($field->equals($original_field)) {
1409             $field->generateSampleItems();
1410           }
1411           break;
1412       }
1413     }
1414
1415     return [$modified_entity, $original_values];
1416   }
1417
1418   /**
1419    * Makes the given entity normalization invalid.
1420    *
1421    * @param array $normalization
1422    *   An entity normalization.
1423    * @param string $entity_key
1424    *   The entity key whose normalization to make invalid.
1425    *
1426    * @return array
1427    *   The updated entity normalization, now invalid.
1428    */
1429   protected function makeNormalizationInvalid(array $normalization, $entity_key) {
1430     $entity_type = $this->entity->getEntityType();
1431     switch ($entity_key) {
1432       case 'label':
1433         // Add a second label to this entity to make it invalid.
1434         $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName;
1435         $normalization[$label_field][1]['value'] = 'Second Title';
1436         break;
1437       case 'id':
1438         $normalization[$entity_type->getKey('id')][0]['value'] = $this->anotherEntity->id();
1439         break;
1440       case 'uuid':
1441         $normalization[$entity_type->getKey('uuid')][0]['value'] = $this->anotherEntity->uuid();
1442         break;
1443     }
1444     return $normalization;
1445   }
1446
1447   /**
1448    * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1449    *
1450    * Asserting a 406 response should be easy, but it's not, due to bugs.
1451    *
1452    * Drupal returns a 403 response instead of a 406 response when:
1453    * - there is a canonical route, i.e. one that serves HTML
1454    * - unless the user is logged in with any non-global authentication provider,
1455    *   because then they tried to access a route that requires the user to be
1456    *   authenticated, but they used an authentication provider that is only
1457    *   accepted for specific routes, and HTML routes never have such specific
1458    *   authentication providers specified. (By default, only 'cookie' is a
1459    *   global authentication provider.)
1460    *
1461    * @todo Remove this in https://www.drupal.org/node/2805279.
1462    *
1463    * @param \Psr\Http\Message\ResponseInterface $response
1464    *   The response to assert.
1465    */
1466   protected function assert406Response(ResponseInterface $response) {
1467     if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1468       $this->assertSame(403, $response->getStatusCode());
1469     }
1470     else {
1471       // This is the desired response.
1472       $this->assertSame(406, $response->getStatusCode());
1473     }
1474   }
1475
1476   /**
1477    * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1478    *
1479    * @param \Drupal\Core\Url $url
1480    *   URL to request.
1481    * @param array $request_options
1482    *   Request options to apply.
1483    */
1484   protected function assertResourceNotAvailable(Url $url, array $request_options) {
1485     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1486     $response = $this->request('GET', $url, $request_options);
1487     if (!$has_canonical_url) {
1488       $this->assertSame(404, $response->getStatusCode());
1489     }
1490     else {
1491       $this->assert406Response($response);
1492     }
1493   }
1494
1495 }