Upgraded drupal core with security updates
[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\Core\Cache\Cache;
7 use Drupal\Core\Config\Entity\ConfigEntityInterface;
8 use Drupal\Core\Entity\FieldableEntityInterface;
9 use Drupal\Core\Url;
10 use Drupal\field\Entity\FieldConfig;
11 use Drupal\field\Entity\FieldStorageConfig;
12 use Drupal\Tests\rest\Functional\ResourceTestBase;
13 use GuzzleHttp\RequestOptions;
14 use Psr\Http\Message\ResponseInterface;
15
16 /**
17  * Even though there is the generic EntityResource, it's necessary for every
18  * entity type to have its own test, because they each have different fields,
19  * validation constraints, et cetera. It's not because the generic case works,
20  * that every case works.
21  *
22  * Furthermore, it's necessary to test every format separately, because there
23  * can be entity type-specific normalization or serialization problems.
24  *
25  * Subclass this for every entity type. Also respect instructions in
26  * \Drupal\rest\Tests\ResourceTestBase.
27  *
28  * For example, for the node test coverage, there is the (abstract)
29  * \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase, which
30  * is then again subclassed for every authentication provider:
31  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonAnonTest
32  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonBasicAuthTest
33  * - \Drupal\Tests\rest\Functional\EntityResource\Node\NodeJsonCookieTest
34  * But the HAL module also adds a new format ('hal_json'), so that format also
35  * needs test coverage (for its own peculiarities in normalization & encoding):
36  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonAnonTest
37  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonBasicAuthTest
38  * - \Drupal\Tests\hal\Functional\EntityResource\Node\NodeHalJsonCookieTest
39  *
40  * In other words: for every entity type there should be:
41  * 1. an abstract subclass that includes the entity type-specific authorization
42  *    (permissions or perhaps custom access control handling, such as node
43  *    grants), plus
44  * 2. a concrete subclass extending the abstract entity type-specific subclass
45  *    that specifies the exact @code $format @endcode, @code $mimeType @endcode
46  *    and @code $auth @endcode for this concrete test. Usually that's all that's
47  *    necessary: most concrete subclasses will be very thin.
48  *
49  * For every of these concrete subclasses, a comprehensive test scenario will
50  * run per HTTP method:
51  * - ::testGet()
52  * - ::testPost()
53  * - ::testPatch()
54  * - ::testDelete()
55  *
56  * If there is an entity type-specific edge case scenario to test, then add that
57  * to the entity type-specific abstract subclass. Example:
58  * \Drupal\Tests\rest\Functional\EntityResource\Comment\CommentResourceTestBase::testPostDxWithoutCriticalBaseFields
59  *
60  * If there is an entity type-specific format-specific edge case to test, then
61  * add that to a concrete subclass. Example:
62  * \Drupal\Tests\hal\Functional\EntityResource\Comment\CommentHalJsonTestBase::$patchProtectedFieldNames
63  */
64 abstract class EntityResourceTestBase extends ResourceTestBase {
65
66   /**
67    * The tested entity type.
68    *
69    * @var string
70    */
71   protected static $entityTypeId = NULL;
72
73   /**
74    * The fields that are protected against modification during PATCH requests.
75    *
76    * @var string[]
77    */
78   protected static $patchProtectedFieldNames;
79
80   /**
81    * Optionally specify which field is the 'label' field. Some entities specify
82    * a 'label_callback', but not a 'label' entity key. For example: User.
83    *
84    * @see ::getInvalidNormalizedEntityToCreate
85    *
86    * @var string|null
87    */
88   protected static $labelFieldName = NULL;
89
90   /**
91    * The entity ID for the first created entity in testPost().
92    *
93    * The default value of 2 should work for most content entities.
94    *
95    * @see ::testPost()
96    *
97    * @var string|int
98    */
99   protected static $firstCreatedEntityId = 2;
100
101   /**
102    * The entity ID for the second created entity in testPost().
103    *
104    * The default value of 3 should work for most content entities.
105    *
106    * @see ::testPost()
107    *
108    * @var string|int
109    */
110   protected static $secondCreatedEntityId = 3;
111
112   /**
113    * The main entity used for testing.
114    *
115    * @var \Drupal\Core\Entity\EntityInterface
116    */
117   protected $entity;
118
119   /**
120    * The entity storage.
121    *
122    * @var \Drupal\Core\Entity\EntityStorageInterface
123    */
124   protected $entityStorage;
125
126   /**
127    * Modules to install.
128    *
129    * @var array
130    */
131   public static $modules = ['rest_test', 'text'];
132
133   /**
134    * Provides an entity resource.
135    */
136   protected function provisionEntityResource() {
137     // It's possible to not have any authentication providers enabled, when
138     // testing public (anonymous) usage of a REST resource.
139     $auth = isset(static::$auth) ? [static::$auth] : [];
140     $this->provisionResource([static::$format], $auth);
141   }
142
143   /**
144    * {@inheritdoc}
145    */
146   public function setUp() {
147     parent::setUp();
148
149     // Calculate REST Resource config entity ID.
150     static::$resourceConfigId = 'entity.' . static::$entityTypeId;
151
152     $this->serializer = $this->container->get('serializer');
153     $this->entityStorage = $this->container->get('entity_type.manager')
154       ->getStorage(static::$entityTypeId);
155
156     // Create an entity.
157     $this->entity = $this->createEntity();
158
159     if ($this->entity instanceof FieldableEntityInterface) {
160       // Add access-protected field.
161       FieldStorageConfig::create([
162         'entity_type' => static::$entityTypeId,
163         'field_name' => 'field_rest_test',
164         'type' => 'text',
165       ])
166         ->setCardinality(1)
167         ->save();
168       FieldConfig::create([
169         'entity_type' => static::$entityTypeId,
170         'field_name' => 'field_rest_test',
171         'bundle' => $this->entity->bundle(),
172       ])
173         ->setLabel('Test field')
174         ->setTranslatable(FALSE)
175         ->save();
176
177       // Reload entity so that it has the new field.
178       $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
179
180       // Set a default value on the field.
181       $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']);
182       $this->entity->save();
183     }
184   }
185
186   /**
187    * Creates the entity to be tested.
188    *
189    * @return \Drupal\Core\Entity\EntityInterface
190    *   The entity to be tested.
191    */
192   abstract protected function createEntity();
193
194   /**
195    * Returns the expected normalization of the entity.
196    *
197    * @see ::createEntity()
198    *
199    * @return array
200    */
201   abstract protected function getExpectedNormalizedEntity();
202
203   /**
204    * Returns the normalized POST entity.
205    *
206    * @see ::testPost
207    *
208    * @return array
209    */
210   abstract protected function getNormalizedPostEntity();
211
212   /**
213    * Returns the normalized PATCH entity.
214    *
215    * By default, reuses ::getNormalizedPostEntity(), which works fine for most
216    * entity types. A counterexample: the 'comment' entity type.
217    *
218    * @see ::testPatch
219    *
220    * @return array
221    */
222   protected function getNormalizedPatchEntity() {
223     return $this->getNormalizedPostEntity();
224   }
225
226   /**
227    * {@inheritdoc}
228    */
229   protected function getExpectedUnauthorizedAccessMessage($method) {
230
231     if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
232       return $this->getExpectedBCUnauthorizedAccessMessage($method);
233     }
234
235     $permission = $this->entity->getEntityType()->getAdminPermission();
236     if ($permission !== FALSE) {
237       return "The '{$permission}' permission is required.";
238     }
239
240     $http_method_to_entity_operation = [
241       'GET' => 'view',
242       'POST' => 'create',
243       'PATCH' => 'update',
244       'DELETE' => 'delete',
245     ];
246     $operation = $http_method_to_entity_operation[$method];
247     $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
248
249     if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
250       $message .= ' of bundle ' . $this->entity->bundle();
251     }
252
253     return "$message.";
254   }
255
256   /**
257    * {@inheritdoc}
258    */
259   protected function getExpectedBcUnauthorizedAccessMessage($method) {
260     return "The 'restful " . strtolower($method) . " entity:" . $this->entity->getEntityTypeId() . "' permission is required.";
261   }
262
263   /**
264    * The expected cache tags for the GET/HEAD response of the test entity.
265    *
266    * @see ::testGet
267    *
268    * @return string[]
269    */
270   protected function getExpectedCacheTags() {
271     $expected_cache_tags = [
272       'config:rest.resource.entity.' . static::$entityTypeId,
273     ];
274     if (!static::$auth) {
275       $expected_cache_tags[] = 'config:user.role.anonymous';
276     }
277     $expected_cache_tags[] = 'http_response';
278     return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
279   }
280
281   /**
282    * The expected cache contexts for the GET/HEAD response of the test entity.
283    *
284    * @see ::testGet
285    *
286    * @return string[]
287    */
288   protected function getExpectedCacheContexts() {
289     return [
290       'url.site',
291       'user.permissions',
292     ];
293   }
294
295   /**
296    * Test a GET request for an entity, plus edge cases to ensure good DX.
297    */
298   public function testGet() {
299     $this->initAuthentication();
300     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
301
302     // The URL and Guzzle request options that will be used in this test. The
303     // request options will be modified/expanded throughout this test:
304     // - to first test all mistakes a developer might make, and assert that the
305     //   error responses provide a good DX
306     // - to eventually result in a well-formed request that succeeds.
307     $url = $this->getEntityResourceUrl();
308     $request_options = [];
309
310
311     // DX: 404 when resource not provisioned, 403 if canonical route. HTML
312     // response because missing ?_format query string.
313     $response = $this->request('GET', $url, $request_options);
314     $this->assertSame($has_canonical_url ? 403 : 404, $response->getStatusCode());
315     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
316
317
318     $url->setOption('query', ['_format' => static::$format]);
319
320
321     // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
322     // response because ?_format query string is present.
323     $response = $this->request('GET', $url, $request_options);
324     if ($has_canonical_url) {
325       $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
326     }
327     else {
328       $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
329     }
330
331
332     $this->provisionEntityResource();
333     // Simulate the developer again forgetting the ?_format query string.
334     $url->setOption('query', []);
335
336
337
338     // DX: 406 when ?_format is missing, except when requesting a canonical HTML
339     // route.
340     $response = $this->request('GET', $url, $request_options);
341     if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
342       $this->assertSame(403, $response->getStatusCode());
343     }
344     else {
345       $this->assert406Response($response);
346     }
347
348
349     $url->setOption('query', ['_format' => static::$format]);
350
351
352     // DX: forgetting authentication: authentication provider-specific error
353     // response.
354     if (static::$auth) {
355       $response = $this->request('GET', $url, $request_options);
356       $this->assertResponseWhenMissingAuthentication($response);
357     }
358
359     $request_options[RequestOptions::HEADERS]['REST-test-auth'] = '1';
360
361     // DX: 403 when attempting to use unallowed authentication provider.
362     $response = $this->request('GET', $url, $request_options);
363     $this->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
364
365     unset($request_options[RequestOptions::HEADERS]['REST-test-auth']);
366     $request_options[RequestOptions::HEADERS]['REST-test-auth-global'] = '1';
367
368     // DX: 403 when attempting to use unallowed global authentication provider.
369     $response = $this->request('GET', $url, $request_options);
370     $this->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
371
372     unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']);
373     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
374
375
376     // DX: 403 when unauthorized.
377     $response = $this->request('GET', $url, $request_options);
378     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
379     $this->assertArrayNotHasKey('Link', $response->getHeaders());
380
381
382
383     $this->setUpAuthorization('GET');
384
385
386     // 200 for well-formed HEAD request.
387     $response = $this->request('HEAD', $url, $request_options);
388     $this->assertResourceResponse(200, '', $response);
389     if (!$this->account) {
390       $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache'));
391     }
392     else {
393       $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
394     }
395     $head_headers = $response->getHeaders();
396
397     // 200 for well-formed GET request. Page Cache hit because of HEAD request.
398     $response = $this->request('GET', $url, $request_options);
399     $this->assertResourceResponse(200, FALSE, $response);
400     if (!static::$auth) {
401       $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache'));
402     }
403     else {
404       $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
405     }
406     $cache_tags_header_value = $response->getHeader('X-Drupal-Cache-Tags')[0];
407     $this->assertEquals($this->getExpectedCacheTags(), empty($cache_tags_header_value) ? [] : explode(' ', $cache_tags_header_value));
408     $cache_contexts_header_value = $response->getHeader('X-Drupal-Cache-Contexts')[0];
409     $this->assertEquals($this->getExpectedCacheContexts(), empty($cache_contexts_header_value) ? [] : explode(' ', $cache_contexts_header_value));
410     // Sort the serialization data first so we can do an identical comparison
411     // for the keys with the array order the same (it needs to match with
412     // identical comparison).
413     $expected = $this->getExpectedNormalizedEntity();
414     ksort($expected);
415     $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
416     ksort($actual);
417     $this->assertSame($expected, $actual);
418
419     // Not only assert the normalization, also assert deserialization of the
420     // response results in the expected object.
421     $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format);
422     $this->assertSame($unserialized->uuid(), $this->entity->uuid());
423     // Finally, assert that the expected 'Link' headers are present.
424     if ($this->entity->getEntityType()->getLinkTemplates()) {
425       $this->assertArrayHasKey('Link', $response->getHeaders());
426       $link_relation_type_manager = $this->container->get('plugin.manager.link_relation_type');
427       $expected_link_relation_headers = array_map(function ($rel) use ($link_relation_type_manager) {
428         $definition = $link_relation_type_manager->getDefinition($rel, FALSE);
429         return (!empty($definition['uri']))
430           ? $definition['uri']
431           : $rel;
432       }, array_keys($this->entity->getEntityType()->getLinkTemplates()));
433       $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
434         $matches = [];
435         if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
436           return $matches[1];
437         }
438         return FALSE;
439       };
440       $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
441     }
442     $get_headers = $response->getHeaders();
443
444     // Verify that the GET and HEAD responses are the same. The only difference
445     // is that there's no body. For this reason the 'Transfer-Encoding' header
446     // is also added to the list of headers to ignore, as this could be added to
447     // GET requests - depending on web server configuration. This would usually
448     // be 'Transfer-Encoding: chunked'.
449     $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding'];
450     foreach ($ignored_headers as $ignored_header) {
451       unset($head_headers[$ignored_header]);
452       unset($get_headers[$ignored_header]);
453     }
454     $this->assertSame($get_headers, $head_headers);
455
456     // Only run this for fieldable entities. It doesn't make sense for config
457     // entities as config values are already casted. They also run through the
458     // ConfigEntityNormalizer, which doesn't deal with fields individually.
459     if ($this->entity instanceof FieldableEntityInterface) {
460       $this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
461       // Rebuild the container so new config is reflected in the removal of the
462       // PrimitiveDataNormalizer.
463       $this->rebuildAll();
464
465
466       $response = $this->request('GET', $url, $request_options);
467       $this->assertResourceResponse(200, FALSE, $response);
468
469
470       // Again do an identical comparison, but this time transform the expected
471       // normalized entity's values to strings. This ensures the BC layer for
472       // bc_primitives_as_strings works as expected.
473       $expected = $this->getExpectedNormalizedEntity();
474       // Config entities are not affected.
475       // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
476       $expected = static::castToString($expected);
477       ksort($expected);
478       $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
479       ksort($actual);
480       $this->assertSame($expected, $actual);
481     }
482
483
484     // BC: rest_update_8203().
485     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
486     $this->refreshTestStateAfterRestConfigChange();
487
488
489     // DX: 403 when unauthorized.
490     $response = $this->request('GET', $url, $request_options);
491     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
492
493
494     $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
495
496
497     // 200 for well-formed request.
498     $response = $this->request('GET', $url, $request_options);
499     $this->assertResourceResponse(200, FALSE, $response);
500
501
502     $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
503     $this->refreshTestStateAfterRestConfigChange();
504
505
506     // DX: upon disabling a resource, it's immediately no longer available.
507     $this->assertResourceNotAvailable($url, $request_options);
508
509
510     $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
511     $this->refreshTestStateAfterRestConfigChange();
512
513
514     // DX: upon re-enabling a resource, immediate 200.
515     $response = $this->request('GET', $url, $request_options);
516     $this->assertResourceResponse(200, FALSE, $response);
517
518
519     $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
520     $this->refreshTestStateAfterRestConfigChange();
521
522
523     // DX: upon deleting a resource, it's immediately no longer available.
524     $this->assertResourceNotAvailable($url, $request_options);
525
526
527     $this->provisionEntityResource();
528     $url->setOption('query', ['_format' => 'non_existing_format']);
529
530
531     // DX: 406 when requesting unsupported format.
532     $response = $this->request('GET', $url, $request_options);
533     $this->assert406Response($response);
534     $this->assertNotSame([static::$mimeType], $response->getHeader('Content-Type'));
535
536
537     $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
538
539
540     // DX: 406 when requesting unsupported format but specifying Accept header.
541     // @todo Update in https://www.drupal.org/node/2825347.
542     $response = $this->request('GET', $url, $request_options);
543     $this->assert406Response($response);
544     $this->assertSame(['application/json'], $response->getHeader('Content-Type'));
545
546
547     $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format);
548     $url->setRouteParameter(static::$entityTypeId, 987654321);
549     $url->setOption('query', ['_format' => static::$format]);
550
551
552     // DX: 404 when GETting non-existing entity.
553     $response = $this->request('GET', $url, $request_options);
554     $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
555     $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET.' . static::$format . '")';
556     $this->assertResourceErrorResponse(404, $message, $response);
557   }
558
559   /**
560    * Transforms a normalization: casts all non-string types to strings.
561    *
562    * @param array $normalization
563    *   A normalization to transform.
564    *
565    * @return array
566    *   The transformed normalization.
567    */
568   protected static function castToString(array $normalization) {
569     foreach ($normalization as $key => $value) {
570       if (is_bool($value)) {
571         $normalization[$key] = (string) (int) $value;
572       }
573       elseif (is_int($value) || is_float($value)) {
574         $normalization[$key] = (string) $value;
575       }
576       elseif (is_array($value)) {
577         $normalization[$key] = static::castToString($value);
578       }
579     }
580     return $normalization;
581   }
582
583   /**
584    * Tests a POST request for an entity, plus edge cases to ensure good DX.
585    */
586   public function testPost() {
587     // @todo Remove this in https://www.drupal.org/node/2300677.
588     if ($this->entity instanceof ConfigEntityInterface) {
589       $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.');
590       return;
591     }
592
593     $this->initAuthentication();
594     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
595
596     // Try with all of the following request bodies.
597     $unparseable_request_body = '!{>}<';
598     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
599     $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
600     $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), static::$format);
601     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], static::$format);
602     $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
603
604     // The URL and Guzzle request options that will be used in this test. The
605     // request options will be modified/expanded throughout this test:
606     // - to first test all mistakes a developer might make, and assert that the
607     //   error responses provide a good DX
608     // - to eventually result in a well-formed request that succeeds.
609     $url = $this->getEntityResourcePostUrl();
610     $request_options = [];
611
612
613     // DX: 404 when resource not provisioned. HTML response because missing
614     // ?_format query string.
615     $response = $this->request('POST', $url, $request_options);
616     $this->assertSame(404, $response->getStatusCode());
617     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
618
619
620     $url->setOption('query', ['_format' => static::$format]);
621
622
623     // DX: 404 when resource not provisioned.
624     $response = $this->request('POST', $url, $request_options);
625     $this->assertResourceErrorResponse(404, 'No route found for "POST ' . str_replace($this->baseUrl, '', $this->getEntityResourcePostUrl()->setAbsolute()->toString()) . '"', $response);
626
627
628     $this->provisionEntityResource();
629     // Simulate the developer again forgetting the ?_format query string.
630     $url->setOption('query', []);
631
632
633     // DX: 415 when no Content-Type request header. HTML response because
634     // missing ?_format query string.
635     $response = $this->request('POST', $url, $request_options);
636     $this->assertSame(415, $response->getStatusCode());
637     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
638     $this->assertContains(htmlspecialchars('No "Content-Type" request header specified'), (string) $response->getBody());
639
640
641     $url->setOption('query', ['_format' => static::$format]);
642
643
644     // DX: 415 when no Content-Type request header.
645     $response = $this->request('POST', $url, $request_options);
646     $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
647
648
649     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
650
651
652     // DX: 400 when no request body.
653     $response = $this->request('POST', $url, $request_options);
654     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
655
656
657     $request_options[RequestOptions::BODY] = $unparseable_request_body;
658
659
660     // DX: 400 when unparseable request body.
661     $response = $this->request('POST', $url, $request_options);
662     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
663
664
665     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
666
667
668     if (static::$auth) {
669       // DX: forgetting authentication: authentication provider-specific error
670       // response.
671       $response = $this->request('POST', $url, $request_options);
672       $this->assertResponseWhenMissingAuthentication($response);
673     }
674
675
676     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
677
678
679     // DX: 403 when unauthorized.
680     $response = $this->request('POST', $url, $request_options);
681     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
682
683
684     $this->setUpAuthorization('POST');
685
686
687     // DX: 422 when invalid entity: multiple values sent for single-value field.
688     $response = $this->request('POST', $url, $request_options);
689     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
690     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
691     $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
692
693
694     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
695
696
697     // DX: 422 when invalid entity: UUID field too long.
698     // @todo Fix this in https://www.drupal.org/node/2149851.
699     if ($this->entity->getEntityType()->hasKey('uuid')) {
700       $response = $this->request('POST', $url, $request_options);
701       $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
702     }
703
704
705     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
706
707
708     // DX: 403 when entity contains field without 'edit' access.
709     $response = $this->request('POST', $url, $request_options);
710     $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response);
711
712
713     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
714
715
716     // Before sending a well-formed request, allow the normalization and
717     // authentication provider edge cases to also be tested.
718     $this->assertNormalizationEdgeCases('POST', $url, $request_options);
719     $this->assertAuthenticationEdgeCases('POST', $url, $request_options);
720
721
722     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
723
724
725     // DX: 415 when request body in existing but not allowed format.
726     $response = $this->request('POST', $url, $request_options);
727     $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
728
729
730     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
731
732
733     // 201 for well-formed request.
734     $response = $this->request('POST', $url, $request_options);
735     $this->assertResourceResponse(201, FALSE, $response);
736     if ($has_canonical_url) {
737       $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
738       $this->assertSame([$location], $response->getHeader('Location'));
739     }
740     else {
741       $this->assertSame([], $response->getHeader('Location'));
742     }
743     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
744
745
746     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
747     $this->refreshTestStateAfterRestConfigChange();
748     $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
749
750
751     // DX: 403 when unauthorized.
752     $response = $this->request('POST', $url, $request_options);
753     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
754
755
756     $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
757
758
759     // 201 for well-formed request.
760     // Delete the first created entity in case there is a uniqueness constraint.
761     $this->entityStorage->load(static::$firstCreatedEntityId)->delete();
762     $response = $this->request('POST', $url, $request_options);
763     $this->assertResourceResponse(201, FALSE, $response);
764     if ($has_canonical_url) {
765       $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('canonical')->setAbsolute(TRUE)->toString();
766       $this->assertSame([$location], $response->getHeader('Location'));
767     }
768     else {
769       $this->assertSame([], $response->getHeader('Location'));
770     }
771     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
772   }
773
774   /**
775    * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
776    */
777   public function testPatch() {
778     // @todo Remove this in https://www.drupal.org/node/2300677.
779     if ($this->entity instanceof ConfigEntityInterface) {
780       $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
781       return;
782     }
783
784     $this->initAuthentication();
785     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
786
787     // Try with all of the following request bodies.
788     $unparseable_request_body = '!{>}<';
789     $parseable_valid_request_body   = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
790     $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), static::$format);
791     $parseable_invalid_request_body   = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), static::$format);
792     $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], static::$format);
793
794     // The URL and Guzzle request options that will be used in this test. The
795     // request options will be modified/expanded throughout this test:
796     // - to first test all mistakes a developer might make, and assert that the
797     //   error responses provide a good DX
798     // - to eventually result in a well-formed request that succeeds.
799     $url = $this->getEntityResourceUrl();
800     $request_options = [];
801
802
803     // DX: 404 when resource not provisioned, 405 if canonical route. Plain text
804     // or HTML response because missing ?_format query string.
805     $response = $this->request('PATCH', $url, $request_options);
806     if ($has_canonical_url) {
807       $this->assertSame(405, $response->getStatusCode());
808       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
809       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
810     }
811     else {
812       $this->assertSame(404, $response->getStatusCode());
813       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
814     }
815
816
817     $url->setOption('query', ['_format' => static::$format]);
818
819
820     // DX: 404 when resource not provisioned, 405 if canonical route.
821     $response = $this->request('PATCH', $url, $request_options);
822     if ($has_canonical_url) {
823       $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
824     }
825     else {
826       $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
827     }
828
829
830     $this->provisionEntityResource();
831     // Simulate the developer again forgetting the ?_format query string.
832     $url->setOption('query', []);
833
834
835     // DX: 415 when no Content-Type request header.
836     $response = $this->request('PATCH', $url, $request_options);
837     $this->assertSame(415, $response->getStatusCode());
838     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
839     $this->assertTrue(FALSE !== strpos((string) $response->getBody(), htmlspecialchars('No "Content-Type" request header specified')));
840
841
842     $url->setOption('query', ['_format' => static::$format]);
843
844
845     // DX: 415 when no Content-Type request header.
846     $response = $this->request('PATCH', $url, $request_options);
847     $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
848
849
850     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
851
852
853     // DX: 400 when no request body.
854     $response = $this->request('PATCH', $url, $request_options);
855     $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
856
857
858     $request_options[RequestOptions::BODY] = $unparseable_request_body;
859
860
861     // DX: 400 when unparseable request body.
862     $response = $this->request('PATCH', $url, $request_options);
863     $this->assertResourceErrorResponse(400, 'Syntax error', $response);
864
865
866
867     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
868
869
870     if (static::$auth) {
871       // DX: forgetting authentication: authentication provider-specific error
872       // response.
873       $response = $this->request('PATCH', $url, $request_options);
874       $this->assertResponseWhenMissingAuthentication($response);
875     }
876
877
878     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
879
880
881     // DX: 403 when unauthorized.
882     $response = $this->request('PATCH', $url, $request_options);
883     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
884
885
886     $this->setUpAuthorization('PATCH');
887
888
889     // DX: 422 when invalid entity: multiple values sent for single-value field.
890     $response = $this->request('PATCH', $url, $request_options);
891     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
892     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
893     $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
894
895
896     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
897
898
899     // DX: 403 when entity contains field without 'edit' access.
900     $response = $this->request('PATCH', $url, $request_options);
901     $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);
902
903
904     // DX: 403 when sending PATCH request with read-only fields.
905     // First send all fields (the "maximum normalization"). Assert the expected
906     // error message for the first PATCH-protected field. Remove that field from
907     // the normalization, send another request, assert the next PATCH-protected
908     // field error message. And so on.
909     $max_normalization = $this->getNormalizedPatchEntity() + $this->serializer->normalize($this->entity, static::$format);
910     for ($i = 0; $i < count(static::$patchProtectedFieldNames); $i++) {
911       $max_normalization = $this->removeFieldsFromNormalization($max_normalization, array_slice(static::$patchProtectedFieldNames, 0, $i));
912       $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
913       $response = $this->request('PATCH', $url, $request_options);
914       $this->assertResourceErrorResponse(403, "Access denied on updating field '" . static::$patchProtectedFieldNames[$i] . "'.", $response);
915     }
916
917     // 200 for well-formed request that sends the maximum number of fields.
918     $max_normalization = $this->removeFieldsFromNormalization($max_normalization, static::$patchProtectedFieldNames);
919     $request_options[RequestOptions::BODY] = $this->serializer->serialize($max_normalization, static::$format);
920     $response = $this->request('PATCH', $url, $request_options);
921     $this->assertResourceResponse(200, FALSE, $response);
922
923
924     $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
925
926
927     // Before sending a well-formed request, allow the normalization and
928     // authentication provider edge cases to also be tested.
929     $this->assertNormalizationEdgeCases('PATCH', $url, $request_options);
930     $this->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
931
932
933     $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
934
935
936     // DX: 415 when request body in existing but not allowed format.
937     $response = $this->request('PATCH', $url, $request_options);
938     $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
939
940
941     $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
942
943
944     // 200 for well-formed request.
945     $response = $this->request('PATCH', $url, $request_options);
946     $this->assertResourceResponse(200, FALSE, $response);
947     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
948     // Ensure that fields do not get deleted if they're not present in the PATCH
949     // request. Test this using the configurable field that we added, but which
950     // is not sent in the PATCH request.
951     $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test')->value);
952
953
954     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
955     $this->refreshTestStateAfterRestConfigChange();
956     $request_options[RequestOptions::BODY] = $parseable_valid_request_body_2;
957
958
959     // DX: 403 when unauthorized.
960     $response = $this->request('PATCH', $url, $request_options);
961     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
962
963
964     $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
965
966
967     // 200 for well-formed request.
968     $response = $this->request('PATCH', $url, $request_options);
969     $this->assertResourceResponse(200, FALSE, $response);
970     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
971   }
972
973   /**
974    * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
975    */
976   public function testDelete() {
977     // @todo Remove this in https://www.drupal.org/node/2300677.
978     if ($this->entity instanceof ConfigEntityInterface) {
979       $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.');
980       return;
981     }
982
983     $this->initAuthentication();
984     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
985
986     // The URL and Guzzle request options that will be used in this test. The
987     // request options will be modified/expanded throughout this test:
988     // - to first test all mistakes a developer might make, and assert that the
989     //   error responses provide a good DX
990     // - to eventually result in a well-formed request that succeeds.
991     $url = $this->getEntityResourceUrl();
992     $request_options = [];
993
994
995     // DX: 405 when resource not provisioned, but HTML if canonical route. Plain
996     // text  or HTML response because missing ?_format query string.
997     $response = $this->request('DELETE', $url, $request_options);
998     if ($has_canonical_url) {
999       $this->assertSame(405, $response->getStatusCode());
1000       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1001       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1002     }
1003     else {
1004       $this->assertSame(404, $response->getStatusCode());
1005       $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1006     }
1007
1008
1009     $url->setOption('query', ['_format' => static::$format]);
1010
1011
1012     // DX: 404 when resource not provisioned, 405 if canonical route.
1013     $response = $this->request('DELETE', $url, $request_options);
1014     if ($has_canonical_url) {
1015       $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
1016       $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
1017     }
1018     else {
1019       $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1020     }
1021
1022     $this->provisionEntityResource();
1023
1024
1025     if (static::$auth) {
1026       // DX: forgetting authentication: authentication provider-specific error
1027       // response.
1028       $response = $this->request('DELETE', $url, $request_options);
1029       $this->assertResponseWhenMissingAuthentication($response);
1030     }
1031
1032
1033     $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1034
1035
1036     // DX: 403 when unauthorized.
1037     $response = $this->request('DELETE', $url, $request_options);
1038     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1039
1040
1041     $this->setUpAuthorization('DELETE');
1042
1043
1044     // Before sending a well-formed request, allow the authentication provider's
1045     // edge cases to also be tested.
1046     $this->assertAuthenticationEdgeCases('DELETE', $url, $request_options);
1047
1048
1049     // 204 for well-formed request.
1050     $response = $this->request('DELETE', $url, $request_options);
1051     $this->assertSame(204, $response->getStatusCode());
1052     // DELETE responses should not include a Content-Type header. But Apache
1053     // sets it to 'text/html' by default. We also cannot detect the presence of
1054     // Apache either here in the CLI. For now having this documented here is all
1055     // we can do.
1056     // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
1057     $this->assertSame('', (string) $response->getBody());
1058     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1059
1060
1061     $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
1062     $this->refreshTestStateAfterRestConfigChange();
1063     $this->entity = $this->createEntity();
1064     $url = $this->getEntityResourceUrl()->setOption('query', $url->getOption('query'));
1065
1066
1067     // DX: 403 when unauthorized.
1068     $response = $this->request('DELETE', $url, $request_options);
1069     $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1070
1071
1072     $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
1073
1074
1075     // 204 for well-formed request.
1076     $response = $this->request('DELETE', $url, $request_options);
1077     $this->assertSame(204, $response->getStatusCode());
1078     // @todo Uncomment the following line when https://www.drupal.org/node/2821711 is fixed.
1079     // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
1080     $this->assertSame('', (string) $response->getBody());
1081     $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
1082   }
1083
1084   /**
1085    * {@inheritdoc}
1086    */
1087   protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
1088     // \Drupal\serialization\Normalizer\EntityNormalizer::denormalize(): entity
1089     // types with bundles MUST send their bundle field to be denormalizable.
1090     $entity_type = $this->entity->getEntityType();
1091     if ($entity_type->hasKey('bundle')) {
1092       $bundle_field_name = $this->entity->getEntityType()->getKey('bundle');
1093       $normalization = $this->getNormalizedPostEntity();
1094
1095       // The bundle type itself can be validated only if there's a bundle entity
1096       // type.
1097       if ($entity_type->getBundleEntityType()) {
1098         $normalization[$bundle_field_name] = 'bad_bundle_name';
1099         $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1100
1101
1102         // DX: 400 when incorrect entity type bundle is specified.
1103         // @todo Change to 422 in https://www.drupal.org/node/2827084.
1104         $response = $this->request($method, $url, $request_options);
1105         $this->assertResourceErrorResponse(400, '"bad_bundle_name" is not a valid bundle type for denormalization.', $response);
1106       }
1107
1108
1109       unset($normalization[$bundle_field_name]);
1110       $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
1111
1112
1113       // DX: 400 when no entity type bundle is specified.
1114       // @todo Change to 422 in https://www.drupal.org/node/2827084.
1115       $response = $this->request($method, $url, $request_options);
1116       $this->assertResourceErrorResponse(400, sprintf('Could not determine entity type bundle: "%s" field is missing.', $bundle_field_name), $response);
1117     }
1118   }
1119
1120   /**
1121    * Gets an entity resource's GET/PATCH/DELETE URL.
1122    *
1123    * @return \Drupal\Core\Url
1124    *   The URL to GET/PATCH/DELETE.
1125    */
1126   protected function getEntityResourceUrl() {
1127     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1128     return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId . '/' . $this->entity->id());
1129   }
1130
1131   /**
1132    * Gets an entity resource's POST URL.
1133    *
1134    * @return \Drupal\Core\Url
1135    *   The URL to POST to.
1136    */
1137   protected function getEntityResourcePostUrl() {
1138     $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create');
1139     return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityTypeId);
1140   }
1141
1142   /**
1143    * Makes the given entity normalization invalid.
1144    *
1145    * @param array $normalization
1146    *   An entity normalization.
1147    *
1148    * @return array
1149    *   The updated entity normalization, now invalid.
1150    */
1151   protected function makeNormalizationInvalid(array $normalization) {
1152     // Add a second label to this entity to make it invalid.
1153     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
1154     $normalization[$label_field][1]['value'] = 'Second Title';
1155
1156     return $normalization;
1157   }
1158
1159   /**
1160    * Removes fields from a normalization.
1161    *
1162    * @param array $normalization
1163    *   An entity normalization.
1164    * @param string[] $field_names
1165    *   The field names to remove from the entity normalization.
1166    *
1167    * @return array
1168    *   The updated entity normalization.
1169    *
1170    * @see ::testPatch
1171    */
1172   protected function removeFieldsFromNormalization(array $normalization, $field_names) {
1173     return array_diff_key($normalization, array_flip($field_names));
1174   }
1175
1176   /**
1177    * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1178    *
1179    * Asserting a 406 response should be easy, but it's not, due to bugs.
1180    *
1181    * Drupal returns a 403 response instead of a 406 response when:
1182    * - there is a canonical route, i.e. one that serves HTML
1183    * - unless the user is logged in with any non-global authentication provider,
1184    *   because then they tried to access a route that requires the user to be
1185    *   authenticated, but they used an authentication provider that is only
1186    *   accepted for specific routes, and HTML routes never have such specific
1187    *   authentication providers specified. (By default, only 'cookie' is a
1188    *   global authentication provider.)
1189    *
1190    * @todo Remove this in https://www.drupal.org/node/2805279.
1191    *
1192    * @param \Psr\Http\Message\ResponseInterface $response
1193    *   The response to assert.
1194    */
1195   protected function assert406Response(ResponseInterface $response) {
1196     if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1197       $this->assertSame(403, $response->getStatusCode());
1198     }
1199     else {
1200       // This is the desired response.
1201       $this->assertSame(406, $response->getStatusCode());
1202     }
1203   }
1204
1205   /**
1206    * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1207    *
1208    * @param \Drupal\Core\Url $url
1209    *   URL to request.
1210    * @param array $request_options
1211    *   Request options to apply.
1212    */
1213   protected function assertResourceNotAvailable(Url $url, array $request_options) {
1214     $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
1215     $response = $this->request('GET', $url, $request_options);
1216     if (!$has_canonical_url) {
1217       $this->assertSame(404, $response->getStatusCode());
1218     }
1219     else {
1220       $this->assert406Response($response);
1221     }
1222   }
1223
1224 }