3 namespace Drupal\Tests\rest\Functional\EntityResource;
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;
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;
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.
22 * Furthermore, it's necessary to test every format separately, because there
23 * can be entity type-specific normalization or serialization problems.
25 * Subclass this for every entity type. Also respect instructions in
26 * \Drupal\rest\Tests\ResourceTestBase.
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
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
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.
49 * For every of these concrete subclasses, a comprehensive test scenario will
50 * run per HTTP method:
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
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
64 abstract class EntityResourceTestBase extends ResourceTestBase {
67 * The tested entity type.
71 protected static $entityTypeId = NULL;
74 * The fields that are protected against modification during PATCH requests.
78 protected static $patchProtectedFieldNames;
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.
84 * @see ::getInvalidNormalizedEntityToCreate
88 protected static $labelFieldName = NULL;
91 * The entity ID for the first created entity in testPost().
93 * The default value of 2 should work for most content entities.
99 protected static $firstCreatedEntityId = 2;
102 * The entity ID for the second created entity in testPost().
104 * The default value of 3 should work for most content entities.
110 protected static $secondCreatedEntityId = 3;
113 * The main entity used for testing.
115 * @var \Drupal\Core\Entity\EntityInterface
120 * The entity storage.
122 * @var \Drupal\Core\Entity\EntityStorageInterface
124 protected $entityStorage;
127 * Modules to install.
131 public static $modules = ['rest_test', 'text'];
134 * Provides an entity resource.
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);
146 public function setUp() {
149 // Calculate REST Resource config entity ID.
150 static::$resourceConfigId = 'entity.' . static::$entityTypeId;
152 $this->serializer = $this->container->get('serializer');
153 $this->entityStorage = $this->container->get('entity_type.manager')
154 ->getStorage(static::$entityTypeId);
157 $this->entity = $this->createEntity();
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',
168 FieldConfig::create([
169 'entity_type' => static::$entityTypeId,
170 'field_name' => 'field_rest_test',
171 'bundle' => $this->entity->bundle(),
173 ->setLabel('Test field')
174 ->setTranslatable(FALSE)
177 // Reload entity so that it has the new field.
178 $this->entity = $this->entityStorage->loadUnchanged($this->entity->id());
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();
187 * Creates the entity to be tested.
189 * @return \Drupal\Core\Entity\EntityInterface
190 * The entity to be tested.
192 abstract protected function createEntity();
195 * Returns the expected normalization of the entity.
197 * @see ::createEntity()
201 abstract protected function getExpectedNormalizedEntity();
204 * Returns the normalized POST entity.
210 abstract protected function getNormalizedPostEntity();
213 * Returns the normalized PATCH entity.
215 * By default, reuses ::getNormalizedPostEntity(), which works fine for most
216 * entity types. A counterexample: the 'comment' entity type.
222 protected function getNormalizedPatchEntity() {
223 return $this->getNormalizedPostEntity();
229 protected function getExpectedUnauthorizedAccessMessage($method) {
231 if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
232 return $this->getExpectedBCUnauthorizedAccessMessage($method);
235 $permission = $this->entity->getEntityType()->getAdminPermission();
236 if ($permission !== FALSE) {
237 return "The '{$permission}' permission is required.";
240 $http_method_to_entity_operation = [
244 'DELETE' => 'delete',
246 $operation = $http_method_to_entity_operation[$method];
247 $message = sprintf('You are not authorized to %s this %s entity', $operation, $this->entity->getEntityTypeId());
249 if ($this->entity->bundle() !== $this->entity->getEntityTypeId()) {
250 $message .= ' of bundle ' . $this->entity->bundle();
259 protected function getExpectedBcUnauthorizedAccessMessage($method) {
260 return "The 'restful " . strtolower($method) . " entity:" . $this->entity->getEntityTypeId() . "' permission is required.";
264 * The expected cache tags for the GET/HEAD response of the test entity.
270 protected function getExpectedCacheTags() {
271 $expected_cache_tags = [
272 'config:rest.resource.entity.' . static::$entityTypeId,
274 if (!static::$auth) {
275 $expected_cache_tags[] = 'config:user.role.anonymous';
277 $expected_cache_tags[] = 'http_response';
278 return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags());
282 * The expected cache contexts for the GET/HEAD response of the test entity.
288 protected function getExpectedCacheContexts() {
296 * Test a GET request for an entity, plus edge cases to ensure good DX.
298 public function testGet() {
299 $this->initAuthentication();
300 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
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 = [];
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'));
318 $url->setOption('query', ['_format' => static::$format]);
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);
328 $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
332 $this->provisionEntityResource();
333 // Simulate the developer again forgetting the ?_format query string.
334 $url->setOption('query', []);
338 // DX: 406 when ?_format is missing, except when requesting a canonical HTML
340 $response = $this->request('GET', $url, $request_options);
341 if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) {
342 $this->assertSame(403, $response->getStatusCode());
345 $this->assert406Response($response);
349 $url->setOption('query', ['_format' => static::$format]);
352 // DX: forgetting authentication: authentication provider-specific error
355 $response = $this->request('GET', $url, $request_options);
356 $this->assertResponseWhenMissingAuthentication($response);
359 $request_options[RequestOptions::HEADERS]['REST-test-auth'] = '1';
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);
365 unset($request_options[RequestOptions::HEADERS]['REST-test-auth']);
366 $request_options[RequestOptions::HEADERS]['REST-test-auth-global'] = '1';
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);
372 unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']);
373 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET'));
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());
383 $this->setUpAuthorization('GET');
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'));
393 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
395 $head_headers = $response->getHeaders();
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'));
404 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
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();
415 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
417 $this->assertSame($expected, $actual);
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']))
432 }, array_keys($this->entity->getEntityType()->getLinkTemplates()));
433 $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
435 if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
440 $this->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response->getHeader('Link')));
442 $get_headers = $response->getHeaders();
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]);
454 $this->assertSame($get_headers, $head_headers);
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.
466 $response = $this->request('GET', $url, $request_options);
467 $this->assertResourceResponse(200, FALSE, $response);
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);
478 $actual = $this->serializer->decode((string) $response->getBody(), static::$format);
480 $this->assertSame($expected, $actual);
484 // BC: rest_update_8203().
485 $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
486 $this->refreshTestStateAfterRestConfigChange();
489 // DX: 403 when unauthorized.
490 $response = $this->request('GET', $url, $request_options);
491 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response);
494 $this->grantPermissionsToTestedRole(['restful get entity:' . static::$entityTypeId]);
497 // 200 for well-formed request.
498 $response = $this->request('GET', $url, $request_options);
499 $this->assertResourceResponse(200, FALSE, $response);
502 $this->resourceConfigStorage->load(static::$resourceConfigId)->disable()->save();
503 $this->refreshTestStateAfterRestConfigChange();
506 // DX: upon disabling a resource, it's immediately no longer available.
507 $this->assertResourceNotAvailable($url, $request_options);
510 $this->resourceConfigStorage->load(static::$resourceConfigId)->enable()->save();
511 $this->refreshTestStateAfterRestConfigChange();
514 // DX: upon re-enabling a resource, immediate 200.
515 $response = $this->request('GET', $url, $request_options);
516 $this->assertResourceResponse(200, FALSE, $response);
519 $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
520 $this->refreshTestStateAfterRestConfigChange();
523 // DX: upon deleting a resource, it's immediately no longer available.
524 $this->assertResourceNotAvailable($url, $request_options);
527 $this->provisionEntityResource();
528 $url->setOption('query', ['_format' => 'non_existing_format']);
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'));
537 $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;
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'));
547 $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET.' . static::$format);
548 $url->setRouteParameter(static::$entityTypeId, 987654321);
549 $url->setOption('query', ['_format' => static::$format]);
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);
560 * Transforms a normalization: casts all non-string types to strings.
562 * @param array $normalization
563 * A normalization to transform.
566 * The transformed normalization.
568 protected static function castToString(array $normalization) {
569 foreach ($normalization as $key => $value) {
570 if (is_bool($value)) {
571 $normalization[$key] = (string) (int) $value;
573 elseif (is_int($value) || is_float($value)) {
574 $normalization[$key] = (string) $value;
576 elseif (is_array($value)) {
577 $normalization[$key] = static::castToString($value);
580 return $normalization;
584 * Tests a POST request for an entity, plus edge cases to ensure good DX.
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.');
593 $this->initAuthentication();
594 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
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);
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 = [];
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'));
620 $url->setOption('query', ['_format' => static::$format]);
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);
628 $this->provisionEntityResource();
629 // Simulate the developer again forgetting the ?_format query string.
630 $url->setOption('query', []);
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());
641 $url->setOption('query', ['_format' => static::$format]);
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);
649 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
652 // DX: 400 when no request body.
653 $response = $this->request('POST', $url, $request_options);
654 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
657 $request_options[RequestOptions::BODY] = $unparseable_request_body;
660 // DX: 400 when unparseable request body.
661 $response = $this->request('POST', $url, $request_options);
662 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
665 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
669 // DX: forgetting authentication: authentication provider-specific error
671 $response = $this->request('POST', $url, $request_options);
672 $this->assertResponseWhenMissingAuthentication($response);
676 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
679 // DX: 403 when unauthorized.
680 $response = $this->request('POST', $url, $request_options);
681 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
684 $this->setUpAuthorization('POST');
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);
694 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
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);
705 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
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);
713 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
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);
722 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
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);
730 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
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'));
741 $this->assertSame([], $response->getHeader('Location'));
743 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
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;
751 // DX: 403 when unauthorized.
752 $response = $this->request('POST', $url, $request_options);
753 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
756 $this->grantPermissionsToTestedRole(['restful post entity:' . static::$entityTypeId]);
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'));
769 $this->assertSame([], $response->getHeader('Location'));
771 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
775 * Tests a PATCH request for an entity, plus edge cases to ensure good DX.
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.');
784 $this->initAuthentication();
785 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
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);
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 = [];
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'));
812 $this->assertSame(404, $response->getStatusCode());
813 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
817 $url->setOption('query', ['_format' => static::$format]);
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);
826 $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
830 $this->provisionEntityResource();
831 // Simulate the developer again forgetting the ?_format query string.
832 $url->setOption('query', []);
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')));
842 $url->setOption('query', ['_format' => static::$format]);
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);
850 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
853 // DX: 400 when no request body.
854 $response = $this->request('PATCH', $url, $request_options);
855 $this->assertResourceErrorResponse(400, 'No entity content received.', $response);
858 $request_options[RequestOptions::BODY] = $unparseable_request_body;
861 // DX: 400 when unparseable request body.
862 $response = $this->request('PATCH', $url, $request_options);
863 $this->assertResourceErrorResponse(400, 'Syntax error', $response);
867 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;
871 // DX: forgetting authentication: authentication provider-specific error
873 $response = $this->request('PATCH', $url, $request_options);
874 $this->assertResponseWhenMissingAuthentication($response);
878 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
881 // DX: 403 when unauthorized.
882 $response = $this->request('PATCH', $url, $request_options);
883 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
886 $this->setUpAuthorization('PATCH');
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);
896 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
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);
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);
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);
924 $request_options[RequestOptions::BODY] = $parseable_valid_request_body;
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);
933 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';
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);
941 $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
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);
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;
959 // DX: 403 when unauthorized.
960 $response = $this->request('PATCH', $url, $request_options);
961 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
964 $this->grantPermissionsToTestedRole(['restful patch entity:' . static::$entityTypeId]);
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'));
974 * Tests a DELETE request for an entity, plus edge cases to ensure good DX.
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.');
983 $this->initAuthentication();
984 $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
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 = [];
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'));
1004 $this->assertSame(404, $response->getStatusCode());
1005 $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
1009 $url->setOption('query', ['_format' => static::$format]);
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);
1019 $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getEntityResourceUrl()->setAbsolute()->toString()) . '"', $response);
1022 $this->provisionEntityResource();
1025 if (static::$auth) {
1026 // DX: forgetting authentication: authentication provider-specific error
1028 $response = $this->request('DELETE', $url, $request_options);
1029 $this->assertResponseWhenMissingAuthentication($response);
1033 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH'));
1036 // DX: 403 when unauthorized.
1037 $response = $this->request('DELETE', $url, $request_options);
1038 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1041 $this->setUpAuthorization('DELETE');
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);
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
1056 // $this->assertSame(FALSE, $response->hasHeader('Content-Type'));
1057 $this->assertSame('', (string) $response->getBody());
1058 $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
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'));
1067 // DX: 403 when unauthorized.
1068 $response = $this->request('DELETE', $url, $request_options);
1069 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response);
1072 $this->grantPermissionsToTestedRole(['restful delete entity:' . static::$entityTypeId]);
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'));
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();
1095 // The bundle type itself can be validated only if there's a bundle entity
1097 if ($entity_type->getBundleEntityType()) {
1098 $normalization[$bundle_field_name] = 'bad_bundle_name';
1099 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
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);
1109 unset($normalization[$bundle_field_name]);
1110 $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format);
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);
1121 * Gets an entity resource's GET/PATCH/DELETE URL.
1123 * @return \Drupal\Core\Url
1124 * The URL to GET/PATCH/DELETE.
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());
1132 * Gets an entity resource's POST URL.
1134 * @return \Drupal\Core\Url
1135 * The URL to POST to.
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);
1143 * Makes the given entity normalization invalid.
1145 * @param array $normalization
1146 * An entity normalization.
1149 * The updated entity normalization, now invalid.
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';
1156 return $normalization;
1160 * Removes fields from a normalization.
1162 * @param array $normalization
1163 * An entity normalization.
1164 * @param string[] $field_names
1165 * The field names to remove from the entity normalization.
1168 * The updated entity normalization.
1172 protected function removeFieldsFromNormalization(array $normalization, $field_names) {
1173 return array_diff_key($normalization, array_flip($field_names));
1177 * Asserts a 406 response… or in some cases a 403 response, because weirdness.
1179 * Asserting a 406 response should be easy, but it's not, due to bugs.
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.)
1190 * @todo Remove this in https://www.drupal.org/node/2805279.
1192 * @param \Psr\Http\Message\ResponseInterface $response
1193 * The response to assert.
1195 protected function assert406Response(ResponseInterface $response) {
1196 if ($this->entity->hasLinkTemplate('canonical') && ($this->account && static::$auth !== 'cookie')) {
1197 $this->assertSame(403, $response->getStatusCode());
1200 // This is the desired response.
1201 $this->assertSame(406, $response->getStatusCode());
1206 * Asserts that a resource is unavailable: 404, 406 if it has canonical route.
1208 * @param \Drupal\Core\Url $url
1210 * @param array $request_options
1211 * Request options to apply.
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());
1220 $this->assert406Response($response);