You are here

public function EntityResourceTestBase::testGet in Drupal 8

Same name and namespace in other branches
  1. 9 core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()

Test a GET request for an entity, plus edge cases to ensure good DX.

6 methods override EntityResourceTestBase::testGet()
ImageStyleXmlAnonTest::testGet in core/modules/image/tests/src/Functional/Rest/ImageStyleXmlAnonTest.php
Test a GET request for an entity, plus edge cases to ensure good DX.
ImageStyleXmlBasicAuthTest::testGet in core/modules/image/tests/src/Functional/Rest/ImageStyleXmlBasicAuthTest.php
Test a GET request for an entity, plus edge cases to ensure good DX.
ImageStyleXmlCookieTest::testGet in core/modules/image/tests/src/Functional/Rest/ImageStyleXmlCookieTest.php
Test a GET request for an entity, plus edge cases to ensure good DX.
MessageResourceTestBase::testGet in core/modules/contact/tests/src/Functional/Rest/MessageResourceTestBase.php
Test a GET request for an entity, plus edge cases to ensure good DX.
VocabularyHalJsonAnonTest::testGet in core/modules/taxonomy/tests/src/Functional/Hal/VocabularyHalJsonAnonTest.php
@todo Remove this override in https://www.drupal.org/node/2805281.

... See full list

File

core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php, line 434

Class

EntityResourceTestBase
Even though there is the generic EntityResource, it's necessary for every entity type to have its own test, because they each have different fields, validation constraints, et cetera. It's not because the generic case works, that every case…

Namespace

Drupal\Tests\rest\Functional\EntityResource

Code

public function testGet() {
  $this
    ->initAuthentication();
  $has_canonical_url = $this->entity
    ->hasLinkTemplate('canonical');

  // The URL and Guzzle request options that will be used in this test. The
  // request options will be modified/expanded throughout this test:
  // - to first test all mistakes a developer might make, and assert that the
  //   error responses provide a good DX
  // - to eventually result in a well-formed request that succeeds.
  $url = $this
    ->getEntityResourceUrl();
  $request_options = [];

  // DX: 404 when resource not provisioned, 403 if canonical route. HTML
  // response because missing ?_format query string.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertSame($has_canonical_url ? 403 : 404, $response
    ->getStatusCode());
  $this
    ->assertSame([
    'text/html; charset=UTF-8',
  ], $response
    ->getHeader('Content-Type'));
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);

  // DX: 404 when resource not provisioned, 403 if canonical route. Non-HTML
  // response because ?_format query string is present.
  $response = $this
    ->request('GET', $url, $request_options);
  if ($has_canonical_url) {
    $expected_cacheability = $this
      ->getExpectedUnauthorizedAccessCacheability()
      ->addCacheTags([
      'config:user.role.anonymous',
    ]);
    $expected_cacheability
      ->addCacheableDependency($this
      ->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
    $this
      ->assertResourceErrorResponse(403, $this
      ->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_cacheability
      ->getCacheTags(), $expected_cacheability
      ->getCacheContexts(), 'MISS', FALSE);
  }
  else {
    $this
      ->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this
      ->getEntityResourceUrl()
      ->setAbsolute()
      ->toString()) . '"', $response);
  }
  $this
    ->provisionEntityResource();

  // DX: forgetting authentication: authentication provider-specific error
  // response.
  if (static::$auth) {
    $response = $this
      ->request('GET', $url, $request_options);
    $this
      ->assertResponseWhenMissingAuthentication('GET', $response);
  }
  $request_options[RequestOptions::HEADERS]['REST-test-auth'] = '1';

  // DX: 403 when attempting to use unallowed authentication provider.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
  unset($request_options[RequestOptions::HEADERS]['REST-test-auth']);
  $request_options[RequestOptions::HEADERS]['REST-test-auth-global'] = '1';

  // DX: 403 when attempting to use unallowed global authentication provider.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, 'The used authentication method is not allowed on this route.', $response);
  unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']);
  $request_options = NestedArray::mergeDeep($request_options, $this
    ->getAuthenticationRequestOptions('GET'));

  // First: single format. Drupal will automatically pick the only format.
  $this
    ->provisionEntityResource(TRUE);
  $expected_403_cacheability = $this
    ->getExpectedUnauthorizedAccessCacheability()
    ->addCacheableDependency($this
    ->getExpectedUnauthorizedEntityAccessCacheability(static::$auth !== FALSE));

  // DX: 403 because unauthorized single-format route, ?_format is omittable.
  $url
    ->setOption('query', []);
  $response = $this
    ->request('GET', $url, $request_options);
  if ($has_canonical_url) {
    $this
      ->assertSame(403, $response
      ->getStatusCode());
    $this
      ->assertSame([
      'text/html; charset=UTF-8',
    ], $response
      ->getHeader('Content-Type'));
  }
  else {
    $this
      ->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability
      ->getCacheTags(), $expected_403_cacheability
      ->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
  }
  $this
    ->assertSame(static::$auth ? [] : [
    'MISS',
  ], $response
    ->getHeader('X-Drupal-Cache'));

  // DX: 403 because unauthorized.
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability
    ->getCacheTags(), $expected_403_cacheability
    ->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);

  // Then, what we'll use for the remainder of the test: multiple formats.
  $this
    ->provisionEntityResource();

  // DX: 406 because despite unauthorized, ?_format is not omittable.
  $url
    ->setOption('query', []);
  $response = $this
    ->request('GET', $url, $request_options);
  if ($has_canonical_url) {
    $this
      ->assertSame(403, $response
      ->getStatusCode());
    $this
      ->assertSame([
      'HIT',
    ], $response
      ->getHeader('X-Drupal-Dynamic-Cache'));
  }
  else {
    $this
      ->assertSame(406, $response
      ->getStatusCode());
    $this
      ->assertSame([
      'UNCACHEABLE',
    ], $response
      ->getHeader('X-Drupal-Dynamic-Cache'));
  }
  $this
    ->assertSame([
    'text/html; charset=UTF-8',
  ], $response
    ->getHeader('Content-Type'));
  $this
    ->assertSame(static::$auth ? [] : [
    'MISS',
  ], $response
    ->getHeader('X-Drupal-Cache'));

  // DX: 403 because unauthorized.
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, $this
    ->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability
    ->getCacheTags(), $expected_403_cacheability
    ->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
  $this
    ->assertArrayNotHasKey('Link', $response
    ->getHeaders());
  $this
    ->setUpAuthorization('GET');

  // 200 for well-formed HEAD request.
  $response = $this
    ->request('HEAD', $url, $request_options);
  $is_cacheable_by_dynamic_page_cache = empty(array_intersect([
    'user',
    'session',
  ], $this
    ->getExpectedCacheContexts()));
  $this
    ->assertResourceResponse(200, '', $response, $this
    ->getExpectedCacheTags(), $this
    ->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');
  $head_headers = $response
    ->getHeaders();

  // 200 for well-formed GET request. Page Cache hit because of HEAD request.
  // Same for Dynamic Page Cache hit.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response, $this
    ->getExpectedCacheTags(), $this
    ->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', $is_cacheable_by_dynamic_page_cache ? static::$auth ? 'HIT' : 'MISS' : 'UNCACHEABLE');

  // Assert that Dynamic Page Cache did not store a ResourceResponse object,
  // which needs serialization after every cache hit. Instead, it should
  // contain a flattened response. Otherwise performance suffers.
  // @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::flattenResponse()
  $cache_items = $this->container
    ->get('database')
    ->select('cache_dynamic_page_cache', 'c')
    ->fields('c', [
    'cid',
    'data',
  ])
    ->condition('c.cid', '%[route]=rest.%', 'LIKE')
    ->execute()
    ->fetchAllAssoc('cid');
  if (!$is_cacheable_by_dynamic_page_cache) {
    $this
      ->assertCount(0, $cache_items);
  }
  else {
    $this
      ->assertCount(2, $cache_items);
    $found_cache_redirect = FALSE;
    $found_cached_200_response = FALSE;
    $other_cached_responses_are_4xx = TRUE;
    foreach ($cache_items as $cid => $cache_item) {
      $cached_data = unserialize($cache_item->data);
      if (!isset($cached_data['#cache_redirect'])) {
        $cached_response = $cached_data['#response'];
        if ($cached_response
          ->getStatusCode() === 200) {
          $found_cached_200_response = TRUE;
        }
        elseif (!$cached_response
          ->isClientError()) {
          $other_cached_responses_are_4xx = FALSE;
        }
        $this
          ->assertNotInstanceOf(ResourceResponseInterface::class, $cached_response);
        $this
          ->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
      }
      else {
        $found_cache_redirect = TRUE;
      }
    }
    $this
      ->assertTrue($found_cache_redirect);
    $this
      ->assertTrue($found_cached_200_response);
    $this
      ->assertTrue($other_cached_responses_are_4xx);
  }

  // Sort the serialization data first so we can do an identical comparison
  // for the keys with the array order the same (it needs to match with
  // identical comparison).
  $expected = $this
    ->getExpectedNormalizedEntity();
  static::recursiveKSort($expected);
  $actual = $this->serializer
    ->decode((string) $response
    ->getBody(), static::$format);
  static::recursiveKSort($actual);
  $this
    ->assertSame($expected, $actual);

  // Not only assert the normalization, also assert deserialization of the
  // response results in the expected object.
  // Note: deserialization of the XML format is not supported, so only test
  // this for other formats.
  if (static::$format !== 'xml') {
    $unserialized = $this->serializer
      ->deserialize((string) $response
      ->getBody(), get_class($this->entity), static::$format);
    $this
      ->assertSame($unserialized
      ->uuid(), $this->entity
      ->uuid());
  }

  // Finally, assert that the expected 'Link' headers are present.
  if ($this->entity
    ->getEntityType()
    ->getLinkTemplates()) {
    $this
      ->assertArrayHasKey('Link', $response
      ->getHeaders());
    $link_relation_type_manager = $this->container
      ->get('plugin.manager.link_relation_type');
    $expected_link_relation_headers = array_map(function ($relation_name) use ($link_relation_type_manager) {
      $link_relation_type = $link_relation_type_manager
        ->createInstance($relation_name);
      return $link_relation_type
        ->isRegistered() ? $link_relation_type
        ->getRegisteredName() : $link_relation_type
        ->getExtensionUri();
    }, array_keys($this->entity
      ->getEntityType()
      ->getLinkTemplates()));
    $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) {
      $matches = [];
      if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {
        return $matches[1];
      }
      return FALSE;
    };
    $this
      ->assertSame($expected_link_relation_headers, array_map($parse_rel_from_link_header, $response
      ->getHeader('Link')));
  }
  $get_headers = $response
    ->getHeaders();

  // Verify that the GET and HEAD responses are the same. The only difference
  // is that there's no body. For this reason the 'Transfer-Encoding' and
  // 'Vary' headers are also added to the list of headers to ignore, as they
  // may be added to GET requests, depending on web server configuration. They
  // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'.
  $ignored_headers = [
    'Date',
    'Content-Length',
    'X-Drupal-Cache',
    'X-Drupal-Dynamic-Cache',
    'Transfer-Encoding',
    'Vary',
  ];
  $header_cleaner = function ($headers) use ($ignored_headers) {
    foreach ($headers as $header => $value) {
      if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) {
        unset($headers[$header]);
      }
    }
    return $headers;
  };
  $get_headers = $header_cleaner($get_headers);
  $head_headers = $header_cleaner($head_headers);
  $this
    ->assertSame($get_headers, $head_headers);

  // BC: serialization_update_8302().
  // Only run this for fieldable entities. It doesn't make sense for config
  // entities as config values are already casted. They also run through the
  // ConfigEntityNormalizer, which doesn't deal with fields individually.
  // Also exclude entity_test_map_field — that has a "map" base field, which
  // only became normalizable since Drupal 8.6, so its normalization
  // containing non-stringified numbers or booleans does not break BC.
  if ($this->entity instanceof FieldableEntityInterface && static::$entityTypeId !== 'entity_test_map_field') {

    // Test primitive data casting BC (strings).
    $this
      ->config('serialization.settings')
      ->set('bc_primitives_as_strings', TRUE)
      ->save(TRUE);

    // Rebuild the container so new config is reflected in the addition of the
    // PrimitiveDataNormalizer.
    $this
      ->rebuildAll();
    $response = $this
      ->request('GET', $url, $request_options);
    $this
      ->assertResourceResponse(200, FALSE, $response, $this
      ->getExpectedCacheTags(), $this
      ->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');

    // Again do an identical comparison, but this time transform the expected
    // normalized entity's values to strings. This ensures the BC layer for
    // bc_primitives_as_strings works as expected.
    $expected = $this
      ->getExpectedNormalizedEntity();

    // Config entities are not affected.
    // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
    $expected = static::castToString($expected);
    static::recursiveKSort($expected);
    $actual = $this->serializer
      ->decode((string) $response
      ->getBody(), static::$format);
    static::recursiveKSort($actual);
    $this
      ->assertSame($expected, $actual);

    // Reset the config value and rebuild.
    $this
      ->config('serialization.settings')
      ->set('bc_primitives_as_strings', FALSE)
      ->save(TRUE);
    $this
      ->rebuildAll();
  }

  // BC: serialization_update_8401().
  // Only run this for fieldable entities. It doesn't make sense for config
  // entities as config values always use the raw values (as per the config
  // schema), returned directly from the ConfigEntityNormalizer, which
  // doesn't deal with fields individually.
  if ($this->entity instanceof FieldableEntityInterface) {

    // Test the BC settings for timestamp values.
    $this
      ->config('serialization.settings')
      ->set('bc_timestamp_normalizer_unix', TRUE)
      ->save(TRUE);

    // Rebuild the container so new config is reflected in the addition of the
    // TimestampItemNormalizer.
    $this
      ->rebuildAll();
    $response = $this
      ->request('GET', $url, $request_options);
    $this
      ->assertResourceResponse(200, FALSE, $response, $this
      ->getExpectedCacheTags(), $this
      ->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');

    // This ensures the BC layer for bc_timestamp_normalizer_unix works as
    // expected. This method should be using
    // ::formatExpectedTimestampValue() to generate the timestamp value. This
    // will take into account the above config setting.
    $expected = $this
      ->getExpectedNormalizedEntity();

    // Config entities are not affected.
    // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize()
    static::recursiveKSort($expected);
    $actual = $this->serializer
      ->decode((string) $response
      ->getBody(), static::$format);
    static::recursiveKSort($actual);
    $this
      ->assertSame($expected, $actual);

    // Reset the config value and rebuild.
    $this
      ->config('serialization.settings')
      ->set('bc_timestamp_normalizer_unix', FALSE)
      ->save(TRUE);
    $this
      ->rebuildAll();
  }

  // BC: rest_update_8203().
  $this
    ->config('rest.settings')
    ->set('bc_entity_resource_permissions', TRUE)
    ->save(TRUE);
  $this
    ->refreshTestStateAfterRestConfigChange();

  // DX: 403 when unauthorized.
  $response = $this
    ->request('GET', $url, $request_options);
  $expected_403_cacheability = $this
    ->getExpectedUnauthorizedAccessCacheability();

  // Permission checking now happens first, so it's the only cache context we
  // could possibly vary by.
  $expected_403_cacheability
    ->setCacheContexts([
    'user.permissions',
  ]);

  // @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
  if (static::$auth === FALSE) {
    $expected_403_cacheability
      ->addCacheTags([
      'config:user.role.anonymous',
    ]);
  }
  $this
    ->assertResourceErrorResponse(403, $this
    ->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability
    ->getCacheTags(), $expected_403_cacheability
    ->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
  $this
    ->grantPermissionsToTestedRole([
    'restful get entity:' . static::$entityTypeId,
  ]);

  // 200 for well-formed request.
  $response = $this
    ->request('GET', $url, $request_options);
  $expected_cache_tags = $this
    ->getExpectedCacheTags();
  $expected_cache_contexts = $this
    ->getExpectedCacheContexts();

  // @todo Fix BlockAccessControlHandler::mergeCacheabilityFromConditions() in
  //   https://www.drupal.org/node/2867881
  if (static::$entityTypeId === 'block') {
    $expected_cache_contexts = Cache::mergeContexts($expected_cache_contexts, [
      'user.permissions',
    ]);
  }

  // \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies to
  // cacheable anonymous responses: it updates their cacheability. Therefore
  // we must update our cacheability expectations for anonymous responses
  // accordingly.
  if (!static::$auth && in_array('user.permissions', $expected_cache_contexts, TRUE)) {
    $expected_cache_tags = Cache::mergeTags($expected_cache_tags, [
      'config:user.role.anonymous',
    ]);
  }
  $this
    ->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');
  $this->resourceConfigStorage
    ->load(static::$resourceConfigId)
    ->disable()
    ->save();
  $this
    ->refreshTestStateAfterRestConfigChange();

  // DX: upon disabling a resource, it's immediately no longer available.
  $this
    ->assertResourceNotAvailable($url, $request_options);
  $this->resourceConfigStorage
    ->load(static::$resourceConfigId)
    ->enable()
    ->save();
  $this
    ->refreshTestStateAfterRestConfigChange();

  // DX: upon re-enabling a resource, immediate 200.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response, $expected_cache_tags, $expected_cache_contexts, static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');
  $this->resourceConfigStorage
    ->load(static::$resourceConfigId)
    ->delete();
  $this
    ->refreshTestStateAfterRestConfigChange();

  // DX: upon deleting a resource, it's immediately no longer available.
  $this
    ->assertResourceNotAvailable($url, $request_options);
  $this
    ->provisionEntityResource();
  $url
    ->setOption('query', [
    '_format' => 'non_existing_format',
  ]);

  // DX: 406 when requesting unsupported format.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assert406Response($response);
  $this
    ->assertSame([
    'text/plain; charset=UTF-8',
  ], $response
    ->getHeader('Content-Type'));
  $request_options[RequestOptions::HEADERS]['Accept'] = static::$mimeType;

  // DX: 406 when requesting unsupported format but specifying Accept header:
  // should result in a text/plain response.
  $response = $this
    ->request('GET', $url, $request_options);
  $this
    ->assert406Response($response);
  $this
    ->assertSame([
    'text/plain; charset=UTF-8',
  ], $response
    ->getHeader('Content-Type'));
  $url = Url::fromRoute('rest.entity.' . static::$entityTypeId . '.GET');
  $url
    ->setRouteParameter(static::$entityTypeId, 987654321);
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);

  // DX: 404 when GETting non-existing entity.
  $response = $this
    ->request('GET', $url, $request_options);
  $path = str_replace('987654321', '{' . static::$entityTypeId . '}', $url
    ->setAbsolute()
    ->setOptions([
    'base_url' => '',
    'query' => [],
  ])
    ->toString());
  $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityTypeId . '.GET")';
  $this
    ->assertResourceErrorResponse(404, $message, $response);
}