Chris@18: '1.0', Chris@18: 'meta' => [ Chris@18: 'links' => ['self' => ['href' => 'http://jsonapi.org/format/1.0/']], Chris@18: ], Chris@18: ]; Chris@18: Chris@18: /** Chris@18: * The entity being tested. Chris@18: * Chris@18: * @var \Drupal\Core\Entity\EntityInterface Chris@18: */ Chris@18: protected $entity; Chris@18: Chris@18: /** Chris@18: * Another entity of the same type used for testing. Chris@18: * Chris@18: * @var \Drupal\Core\Entity\EntityInterface Chris@18: */ Chris@18: protected $anotherEntity; Chris@18: Chris@18: /** Chris@18: * The account to use for authentication. Chris@18: * Chris@18: * @var null|\Drupal\Core\Session\AccountInterface Chris@18: */ Chris@18: protected $account; Chris@18: Chris@18: /** Chris@18: * The entity storage. Chris@18: * Chris@18: * @var \Drupal\Core\Entity\EntityStorageInterface Chris@18: */ Chris@18: protected $entityStorage; Chris@18: Chris@18: /** Chris@18: * The UUID key. Chris@18: * Chris@18: * @var string Chris@18: */ Chris@18: protected $uuidKey; Chris@18: Chris@18: /** Chris@18: * The serializer service. Chris@18: * Chris@18: * @var \Symfony\Component\Serializer\Serializer Chris@18: */ Chris@18: protected $serializer; Chris@18: Chris@18: /** Chris@18: * {@inheritdoc} Chris@18: */ Chris@18: public function setUp() { Chris@18: parent::setUp(); Chris@18: Chris@18: $this->serializer = $this->container->get('jsonapi.serializer'); Chris@18: Chris@18: // Ensure the anonymous user role has no permissions at all. Chris@18: $user_role = Role::load(RoleInterface::ANONYMOUS_ID); Chris@18: foreach ($user_role->getPermissions() as $permission) { Chris@18: $user_role->revokePermission($permission); Chris@18: } Chris@18: $user_role->save(); Chris@18: assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.'); Chris@18: Chris@18: // Ensure the authenticated user role has no permissions at all. Chris@18: $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); Chris@18: foreach ($user_role->getPermissions() as $permission) { Chris@18: $user_role->revokePermission($permission); Chris@18: } Chris@18: $user_role->save(); Chris@18: assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.'); Chris@18: Chris@18: // Create an account, which tests will use. Also ensure the @current_user Chris@18: // service this account, to ensure certain access check logic in tests works Chris@18: // as expected. Chris@18: $this->account = $this->createUser(); Chris@18: $this->container->get('current_user')->setAccount($this->account); Chris@18: Chris@18: // Create an entity. Chris@18: $entity_type_manager = $this->container->get('entity_type.manager'); Chris@18: $this->entityStorage = $entity_type_manager->getStorage(static::$entityTypeId); Chris@18: $this->uuidKey = $entity_type_manager->getDefinition(static::$entityTypeId) Chris@18: ->getKey('uuid'); Chris@18: $this->entity = $this->setUpFields($this->createEntity(), $this->account); Chris@18: Chris@18: $this->resourceType = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Sets up additional fields for testing. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The primary test entity. Chris@18: * @param \Drupal\user\UserInterface $account Chris@18: * The primary test user account. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface Chris@18: * The reloaded entity with the new fields attached. Chris@18: * Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: */ Chris@18: protected function setUpFields(EntityInterface $entity, UserInterface $account) { Chris@18: if (!$entity instanceof FieldableEntityInterface) { Chris@18: return $entity; Chris@18: } Chris@18: Chris@18: $entity_bundle = $entity->bundle(); Chris@18: $account_bundle = $account->bundle(); Chris@18: Chris@18: // Add access-protected field. Chris@18: FieldStorageConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_rest_test', Chris@18: 'type' => 'text', Chris@18: ]) Chris@18: ->setCardinality(1) Chris@18: ->save(); Chris@18: FieldConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_rest_test', Chris@18: 'bundle' => $entity_bundle, Chris@18: ]) Chris@18: ->setLabel('Test field') Chris@18: ->setTranslatable(FALSE) Chris@18: ->save(); Chris@18: Chris@18: FieldStorageConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_jsonapi_test_entity_ref', Chris@18: 'type' => 'entity_reference', Chris@18: ]) Chris@18: ->setSetting('target_type', 'user') Chris@18: ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) Chris@18: ->save(); Chris@18: Chris@18: FieldConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_jsonapi_test_entity_ref', Chris@18: 'bundle' => $entity_bundle, Chris@18: ]) Chris@18: ->setTranslatable(FALSE) Chris@18: ->setSetting('handler', 'default') Chris@18: ->setSetting('handler_settings', [ Chris@18: 'target_bundles' => NULL, Chris@18: ]) Chris@18: ->save(); Chris@18: Chris@18: // Add multi-value field. Chris@18: FieldStorageConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_rest_test_multivalue', Chris@18: 'type' => 'string', Chris@18: ]) Chris@18: ->setCardinality(3) Chris@18: ->save(); Chris@18: FieldConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_rest_test_multivalue', Chris@18: 'bundle' => $entity_bundle, Chris@18: ]) Chris@18: ->setLabel('Test field: multi-value') Chris@18: ->setTranslatable(FALSE) Chris@18: ->save(); Chris@18: Chris@18: \Drupal::service('router.builder')->rebuildIfNeeded(); Chris@18: Chris@18: // Reload entity so that it has the new field. Chris@18: $reloaded_entity = $this->entityLoadUnchanged($entity->id()); Chris@18: // Some entity types are not stored, hence they cannot be reloaded. Chris@18: if ($reloaded_entity !== NULL) { Chris@18: $entity = $reloaded_entity; Chris@18: Chris@18: // Set a default value on the fields. Chris@18: $entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']); Chris@18: $entity->set('field_jsonapi_test_entity_ref', ['user' => $account->id()]); Chris@18: $entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]); Chris@18: $entity->save(); Chris@18: } Chris@18: Chris@18: return $entity; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Sets up a collection of entities of the same type for testing. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface[] Chris@18: * The collection of entities to test. Chris@18: * Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: */ Chris@18: protected function getData() { Chris@18: if ($this->entityStorage->getQuery()->count()->execute() < 2) { Chris@18: $this->createAnotherEntity('two'); Chris@18: } Chris@18: $query = $this->entityStorage->getQuery()->sort($this->entity->getEntityType()->getKey('id')); Chris@18: return $this->entityStorage->loadMultiple($query->execute()); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Generates a JSON:API normalization for the given entity. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity to generate a JSON:API normalization for. Chris@18: * @param \Drupal\Core\Url $url Chris@18: * The URL to use as the "self" link. Chris@18: * Chris@18: * @return array Chris@18: * The JSON:API normalization for the given entity. Chris@18: */ Chris@18: protected function normalize(EntityInterface $entity, Url $url) { Chris@18: $self_link = new Link(new CacheableMetadata(), $url, ['self']); Chris@18: $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName); Chris@18: $doc = new JsonApiDocumentTopLevel(new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1), new NullIncludedData(), new LinkCollection(['self' => $self_link])); Chris@18: return $this->serializer->normalize($doc, 'api_json', [ Chris@18: 'resource_type' => $resource_type, Chris@18: 'account' => $this->account, Chris@18: ])->getNormalization(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates the entity to be tested. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface Chris@18: * The entity to be tested. Chris@18: */ Chris@18: abstract protected function createEntity(); Chris@18: Chris@18: /** Chris@18: * Creates another entity to be tested. Chris@18: * Chris@18: * @param mixed $key Chris@18: * A unique key to be used for the ID and/or label of the duplicated entity. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface Chris@18: * Another entity based on $this->entity. Chris@18: * Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: */ Chris@18: protected function createAnotherEntity($key) { Chris@18: $duplicate = $this->getEntityDuplicate($this->entity, $key); Chris@18: // Some entity types are not stored, hence they cannot be reloaded. Chris@18: if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { Chris@18: $duplicate->set('field_rest_test', 'Second collection entity'); Chris@18: } Chris@18: $duplicate->save(); Chris@18: return $duplicate; Chris@18: } Chris@18: Chris@18: /** Chris@18: * {@inheritdoc} Chris@18: */ Chris@18: protected function getEntityDuplicate(EntityInterface $original, $key) { Chris@18: $duplicate = $original->createDuplicate(); Chris@18: if ($label_key = $original->getEntityType()->getKey('label')) { Chris@18: $duplicate->set($label_key, $original->label() . '_' . $key); Chris@18: } Chris@18: if ($duplicate instanceof ConfigEntityInterface && $id_key = $duplicate->getEntityType()->getKey('id')) { Chris@18: $id = $original->id(); Chris@18: $id_key = $duplicate->getEntityType()->getKey('id'); Chris@18: $duplicate->set($id_key, $id . '_' . $key); Chris@18: } Chris@18: return $duplicate; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Returns the expected JSON:API document for the entity. Chris@18: * Chris@18: * @see ::createEntity() Chris@18: * Chris@18: * @return array Chris@18: * A JSON:API response document. Chris@18: */ Chris@18: abstract protected function getExpectedDocument(); Chris@18: Chris@18: /** Chris@18: * Returns the JSON:API POST document. Chris@18: * Chris@18: * @see ::testPostIndividual() Chris@18: * Chris@18: * @return array Chris@18: * A JSON:API request document. Chris@18: */ Chris@18: abstract protected function getPostDocument(); Chris@18: Chris@18: /** Chris@18: * Returns the JSON:API PATCH document. Chris@18: * Chris@18: * By default, reuses ::getPostDocument(), which works fine for most entity Chris@18: * types. A counter example: the 'comment' entity type. Chris@18: * Chris@18: * @see ::testPatchIndividual() Chris@18: * Chris@18: * @return array Chris@18: * A JSON:API request document. Chris@18: */ Chris@18: protected function getPatchDocument() { Chris@18: return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getPostDocument()); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Returns the expected cacheability for an unauthorized response. Chris@18: * Chris@18: * @return \Drupal\Core\Cache\CacheableMetadata Chris@18: * The expected cacheability. Chris@18: */ Chris@18: protected function getExpectedUnauthorizedAccessCacheability() { Chris@18: return (new CacheableMetadata()) Chris@18: ->setCacheTags(['4xx-response', 'http_response']) Chris@18: ->setCacheContexts(['url.site', 'user.permissions']) Chris@18: ->addCacheContexts($this->entity->getEntityType()->isRevisionable() Chris@18: ? ['url.query_args:resourceVersion'] Chris@18: : [] Chris@18: ); Chris@18: } Chris@18: Chris@18: /** Chris@18: * The expected cache tags for the GET/HEAD response of the test entity. Chris@18: * Chris@18: * @param array|null $sparse_fieldset Chris@18: * If a sparse fieldset is being requested, limit the expected cache tags Chris@18: * for this entity's fields to just these fields. Chris@18: * Chris@18: * @return string[] Chris@18: * A set of cache tags. Chris@18: * Chris@18: * @see ::testGetIndividual() Chris@18: */ Chris@18: protected function getExpectedCacheTags(array $sparse_fieldset = NULL) { Chris@18: $expected_cache_tags = [ Chris@18: 'http_response', Chris@18: ]; Chris@18: return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags()); Chris@18: } Chris@18: Chris@18: /** Chris@18: * The expected cache contexts for the GET/HEAD response of the test entity. Chris@18: * Chris@18: * @param array|null $sparse_fieldset Chris@18: * If a sparse fieldset is being requested, limit the expected cache Chris@18: * contexts for this entity's fields to just these fields. Chris@18: * Chris@18: * @return string[] Chris@18: * A set of cache contexts. Chris@18: * Chris@18: * @see ::testGetIndividual() Chris@18: */ Chris@18: protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) { Chris@18: $cache_contexts = [ Chris@18: // Cache contexts for JSON:API URL query parameters. Chris@18: 'url.query_args:fields', Chris@18: 'url.query_args:include', Chris@18: // Drupal defaults. Chris@18: 'url.site', Chris@18: 'user.permissions', Chris@18: ]; Chris@18: $entity_type = $this->entity->getEntityType(); Chris@18: return Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Computes the cacheability for a given entity collection. Chris@18: * Chris@18: * @param \Drupal\Core\Session\AccountInterface $account Chris@18: * An account for which cacheability should be computed (cacheability is Chris@18: * dependent on access). Chris@18: * @param \Drupal\Core\Entity\EntityInterface[] $collection Chris@18: * The entities for which cacheability should be computed. Chris@18: * @param array $sparse_fieldset Chris@18: * (optional) If a sparse fieldset is being requested, limit the expected Chris@18: * cacheability for the collection entities' fields to just those in the Chris@18: * fieldset. NULL means all fields. Chris@18: * @param bool $filtered Chris@18: * Whether the collection is filtered or not. Chris@18: * Chris@18: * @return \Drupal\Core\Cache\CacheableMetadata Chris@18: * The expected cacheability for the given entity collection. Chris@18: */ Chris@18: protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) { Chris@18: $cacheability = array_reduce($collection, function (CacheableMetadata $cacheability, EntityInterface $entity) use ($sparse_fieldset, $account) { Chris@18: $access_result = static::entityAccess($entity, 'view', $account); Chris@18: if (!$access_result->isAllowed()) { Chris@18: $access_result = static::entityAccess($entity, 'view label', $account)->addCacheableDependency($access_result); Chris@18: } Chris@18: $cacheability->addCacheableDependency($access_result); Chris@18: if ($access_result->isAllowed()) { Chris@18: $cacheability->addCacheableDependency($entity); Chris@18: if ($entity instanceof FieldableEntityInterface) { Chris@18: foreach ($entity as $field_name => $field_item_list) { Chris@18: /* @var \Drupal\Core\Field\FieldItemListInterface $field_item_list */ Chris@18: if (is_null($sparse_fieldset) || in_array($field_name, $sparse_fieldset)) { Chris@18: $field_access = static::entityFieldAccess($entity, $field_name, 'view', $account); Chris@18: $cacheability->addCacheableDependency($field_access); Chris@18: if ($field_access->isAllowed()) { Chris@18: foreach ($field_item_list as $field_item) { Chris@18: /* @var \Drupal\Core\Field\FieldItemInterface $field_item */ Chris@18: foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property) { Chris@18: $cacheability->addCacheableDependency(CacheableMetadata::createFromObject($property)); Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: return $cacheability; Chris@18: }, new CacheableMetadata()); Chris@18: $entity_type = reset($collection)->getEntityType(); Chris@18: $cacheability->addCacheTags(['http_response']); Chris@18: $cacheability->addCacheTags($entity_type->getListCacheTags()); Chris@18: $cache_contexts = [ Chris@18: // Cache contexts for JSON:API URL query parameters. Chris@18: 'url.query_args:fields', Chris@18: 'url.query_args:filter', Chris@18: 'url.query_args:include', Chris@18: 'url.query_args:page', Chris@18: 'url.query_args:sort', Chris@18: // Drupal defaults. Chris@18: 'url.site', Chris@18: ]; Chris@18: // If the entity type is revisionable, add a resource version cache context. Chris@18: $cache_contexts = Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: $cacheability->addCacheContexts($cache_contexts); Chris@18: return $cacheability; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Sets up the necessary authorization. Chris@18: * Chris@18: * In case of a test verifying publicly accessible REST resources: grant Chris@18: * permissions to the anonymous user role. Chris@18: * Chris@18: * In case of a test verifying behavior when using a particular authentication Chris@18: * provider: create a user with a particular set of permissions. Chris@18: * Chris@18: * Because of the $method parameter, it's possible to first set up Chris@18: * authentication for only GET, then add POST, et cetera. This then also Chris@18: * allows for verifying a 403 in case of missing authorization. Chris@18: * Chris@18: * @param string $method Chris@18: * The HTTP method for which to set up authentication. Chris@18: * Chris@18: * @see ::grantPermissionsToAnonymousRole() Chris@18: * @see ::grantPermissionsToAuthenticatedRole() Chris@18: */ Chris@18: abstract protected function setUpAuthorization($method); Chris@18: Chris@18: /** Chris@18: * Sets up the necessary authorization for handling revisions. Chris@18: * Chris@18: * @param string $method Chris@18: * The HTTP method for which to set up authentication. Chris@18: * Chris@18: * @see ::testRevisions() Chris@18: */ Chris@18: protected function setUpRevisionAuthorization($method) { Chris@18: assert($method === 'GET', 'Only read operations on revisions are supported.'); Chris@18: $this->setUpAuthorization($method); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Return the expected error message. Chris@18: * Chris@18: * @param string $method Chris@18: * The HTTP method (GET, POST, PATCH, DELETE). Chris@18: * Chris@18: * @return string Chris@18: * The error string. Chris@18: */ Chris@18: protected function getExpectedUnauthorizedAccessMessage($method) { Chris@18: $permission = $this->entity->getEntityType()->getAdminPermission(); Chris@18: if ($permission !== FALSE) { Chris@18: return "The '{$permission}' permission is required."; Chris@18: } Chris@18: Chris@18: return NULL; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Grants permissions to the authenticated role. Chris@18: * Chris@18: * @param string[] $permissions Chris@18: * Permissions to grant. Chris@18: */ Chris@18: protected function grantPermissionsToTestedRole(array $permissions) { Chris@18: $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Revokes permissions from the authenticated role. Chris@18: * Chris@18: * @param string[] $permissions Chris@18: * Permissions to revoke. Chris@18: */ Chris@18: protected function revokePermissionsFromTestedRole(array $permissions) { Chris@18: $role = Role::load(RoleInterface::AUTHENTICATED_ID); Chris@18: foreach ($permissions as $permission) { Chris@18: $role->revokePermission($permission); Chris@18: } Chris@18: $role->trustData()->save(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Asserts that a resource response has the given status code and body. Chris@18: * Chris@18: * @param int $expected_status_code Chris@18: * The expected response status. Chris@18: * @param array|null|false $expected_document Chris@18: * The expected document or NULL if there should not be a response body. Chris@18: * FALSE in case this should not be asserted. Chris@18: * @param \Psr\Http\Message\ResponseInterface $response Chris@18: * The response to assert. Chris@18: * @param string[]|false $expected_cache_tags Chris@18: * (optional) The expected cache tags in the X-Drupal-Cache-Tags response Chris@18: * header, or FALSE if that header should be absent. Defaults to FALSE. Chris@18: * @param string[]|false $expected_cache_contexts Chris@18: * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts Chris@18: * response header, or FALSE if that header should be absent. Defaults to Chris@18: * FALSE. Chris@18: * @param string|false $expected_page_cache_header_value Chris@18: * (optional) The expected X-Drupal-Cache response header value, or FALSE if Chris@18: * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults Chris@18: * to FALSE. Chris@18: * @param string|false $expected_dynamic_page_cache_header_value Chris@18: * (optional) The expected X-Drupal-Dynamic-Cache response header value, or Chris@18: * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. Chris@18: * Defaults to FALSE. Chris@18: */ Chris@18: protected function assertResourceResponse($expected_status_code, $expected_document, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { Chris@18: $this->assertSame($expected_status_code, $response->getStatusCode(), var_export(Json::decode((string) $response->getBody()), TRUE)); Chris@18: if ($expected_status_code === 204) { Chris@18: // DELETE responses should not include a Content-Type header. But Apache Chris@18: // sets it to 'text/html' by default. We also cannot detect the presence Chris@18: // of Apache either here in the CLI. For now having this documented here Chris@18: // is all we can do. Chris@18: /* $this->assertSame(FALSE, $response->hasHeader('Content-Type')); */ Chris@18: $this->assertSame('', (string) $response->getBody()); Chris@18: } Chris@18: else { Chris@18: $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type')); Chris@18: if ($expected_document !== FALSE) { Chris@18: $response_document = Json::decode((string) $response->getBody()); Chris@18: if ($expected_document === NULL) { Chris@18: $this->assertNull($response_document); Chris@18: } Chris@18: else { Chris@18: $this->assertSameDocument($expected_document, $response_document); Chris@18: } Chris@18: } Chris@18: } Chris@18: Chris@18: // Expected cache tags: X-Drupal-Cache-Tags header. Chris@18: $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags')); Chris@18: if (is_array($expected_cache_tags)) { Chris@18: $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); Chris@18: } Chris@18: Chris@18: // Expected cache contexts: X-Drupal-Cache-Contexts header. Chris@18: $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts')); Chris@18: if (is_array($expected_cache_contexts)) { Chris@18: $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts); Chris@18: $this->assertSame($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); Chris@18: } Chris@18: Chris@18: // Expected Page Cache header value: X-Drupal-Cache header. Chris@18: if ($expected_page_cache_header_value !== FALSE) { Chris@18: $this->assertTrue($response->hasHeader('X-Drupal-Cache')); Chris@18: $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]); Chris@18: } Chris@18: else { Chris@18: $this->assertFalse($response->hasHeader('X-Drupal-Cache')); Chris@18: } Chris@18: Chris@18: // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header. Chris@18: if ($expected_dynamic_page_cache_header_value !== FALSE) { Chris@18: $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache')); Chris@18: $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]); Chris@18: } Chris@18: else { Chris@18: $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache')); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Asserts that an expected document matches the response body. Chris@18: * Chris@18: * @param array $expected_document Chris@18: * The expected JSON:API document. Chris@18: * @param array $actual_document Chris@18: * The actual response document to assert. Chris@18: */ Chris@18: protected function assertSameDocument(array $expected_document, array $actual_document) { Chris@18: static::recursiveKsort($expected_document); Chris@18: static::recursiveKsort($actual_document); Chris@18: Chris@18: if (!empty($expected_document['included'])) { Chris@18: static::sortResourceCollection($expected_document['included']); Chris@18: static::sortResourceCollection($actual_document['included']); Chris@18: } Chris@18: Chris@18: if (isset($actual_document['meta']['omitted']) && isset($expected_document['meta']['omitted'])) { Chris@18: $actual_omitted =& $actual_document['meta']['omitted']; Chris@18: $expected_omitted =& $expected_document['meta']['omitted']; Chris@18: static::sortOmittedLinks($actual_omitted); Chris@18: static::sortOmittedLinks($expected_omitted); Chris@18: static::resetOmittedLinkKeys($actual_omitted); Chris@18: static::resetOmittedLinkKeys($expected_omitted); Chris@18: } Chris@18: Chris@18: $expected_keys = array_keys($expected_document); Chris@18: $actual_keys = array_keys($actual_document); Chris@18: $missing_member_names = array_diff($expected_keys, $actual_keys); Chris@18: $extra_member_names = array_diff($actual_keys, $expected_keys); Chris@18: if (!empty($missing_member_names) || !empty($extra_member_names)) { Chris@18: $message_format = "The document members did not match the expected values. Missing: [ %s ]. Unexpected: [ %s ]"; Chris@18: $message = sprintf($message_format, implode(', ', $missing_member_names), implode(', ', $extra_member_names)); Chris@18: $this->assertSame($expected_document, $actual_document, $message); Chris@18: } Chris@18: foreach ($expected_document as $member_name => $expected_member) { Chris@18: $actual_member = $actual_document[$member_name]; Chris@18: $this->assertSame($expected_member, $actual_member, "The '$member_name' member was not as expected."); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Asserts that a resource error response has the given message. Chris@18: * Chris@18: * @param int $expected_status_code Chris@18: * The expected response status. Chris@18: * @param string $expected_message Chris@18: * The expected error message. Chris@18: * @param \Drupal\Core\Url|null $via_link Chris@18: * The source URL for the errors of the response. NULL if the error occurs Chris@18: * for example during entity creation. Chris@18: * @param \Psr\Http\Message\ResponseInterface $response Chris@18: * The error response to assert. Chris@18: * @param string|false $pointer Chris@18: * The expected JSON Pointer to the associated entity in the request Chris@18: * document. See http://jsonapi.org/format/#error-objects. Chris@18: * @param string[]|false $expected_cache_tags Chris@18: * (optional) The expected cache tags in the X-Drupal-Cache-Tags response Chris@18: * header, or FALSE if that header should be absent. Defaults to FALSE. Chris@18: * @param string[]|false $expected_cache_contexts Chris@18: * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts Chris@18: * response header, or FALSE if that header should be absent. Defaults to Chris@18: * FALSE. Chris@18: * @param string|false $expected_page_cache_header_value Chris@18: * (optional) The expected X-Drupal-Cache response header value, or FALSE if Chris@18: * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults Chris@18: * to FALSE. Chris@18: * @param string|false $expected_dynamic_page_cache_header_value Chris@18: * (optional) The expected X-Drupal-Dynamic-Cache response header value, or Chris@18: * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. Chris@18: * Defaults to FALSE. Chris@18: */ Chris@18: protected function assertResourceErrorResponse($expected_status_code, $expected_message, $via_link, ResponseInterface $response, $pointer = FALSE, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { Chris@18: assert(is_null($via_link) || $via_link instanceof Url); Chris@18: $expected_error = []; Chris@18: if (!empty(Response::$statusTexts[$expected_status_code])) { Chris@18: $expected_error['title'] = Response::$statusTexts[$expected_status_code]; Chris@18: } Chris@18: $expected_error['status'] = (string) $expected_status_code; Chris@18: $expected_error['detail'] = $expected_message; Chris@18: if ($via_link) { Chris@18: $expected_error['links']['via']['href'] = $via_link->setAbsolute()->toString(); Chris@18: } Chris@18: if ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) { Chris@18: $expected_error['links']['info']['href'] = $info_url; Chris@18: } Chris@18: if ($pointer !== FALSE) { Chris@18: $expected_error['source']['pointer'] = $pointer; Chris@18: } Chris@18: Chris@18: $expected_document = [ Chris@18: 'jsonapi' => static::$jsonApiMember, Chris@18: 'errors' => [ Chris@18: 0 => $expected_error, Chris@18: ], Chris@18: ]; Chris@18: $this->assertResourceResponse($expected_status_code, $expected_document, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Makes the JSON:API document violate the spec by omitting the resource type. Chris@18: * Chris@18: * @param array $document Chris@18: * A JSON:API document. Chris@18: * Chris@18: * @return array Chris@18: * The same JSON:API document, without its resource type. Chris@18: */ Chris@18: protected function removeResourceTypeFromDocument(array $document) { Chris@18: unset($document['data']['type']); Chris@18: return $document; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Makes the given JSON:API document invalid. Chris@18: * Chris@18: * @param array $document Chris@18: * A JSON:API document. Chris@18: * @param string $entity_key Chris@18: * The entity key whose normalization to make invalid. Chris@18: * Chris@18: * @return array Chris@18: * The updated JSON:API document, now invalid. Chris@18: */ Chris@18: protected function makeNormalizationInvalid(array $document, $entity_key) { Chris@18: $entity_type = $this->entity->getEntityType(); Chris@18: switch ($entity_key) { Chris@18: case 'label': Chris@18: // Add a second label to this entity to make it invalid. Chris@18: $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName; Chris@18: $document['data']['attributes'][$label_field] = [ Chris@18: 0 => $document['data']['attributes'][$label_field], Chris@18: 1 => 'Second Title', Chris@18: ]; Chris@18: break; Chris@18: Chris@18: case 'id': Chris@18: $document['data']['attributes'][$entity_type->getKey('id')] = $this->anotherEntity->id(); Chris@18: break; Chris@18: Chris@18: case 'uuid': Chris@18: $document['data']['id'] = $this->anotherEntity->uuid(); Chris@18: break; Chris@18: } Chris@18: Chris@18: return $document; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests GETting an individual resource, plus edge cases to ensure good DX. Chris@18: */ Chris@18: public function testGetIndividual() { Chris@18: // The URL and Guzzle request options that will be used in this test. The Chris@18: // request options will be modified/expanded throughout this test: Chris@18: // - to first test all mistakes a developer might make, and assert that the Chris@18: // error responses provide a good DX Chris@18: // - to eventually result in a well-formed request that succeeds. Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); Chris@18: /* $url = $this->entity->toUrl('jsonapi'); */ Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // DX: 403 when unauthorized, or 200 if the 'view label' operation is Chris@18: // supported by the entity type. Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: if (!static::$anonymousUsersCanViewLabels) { Chris@18: $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); Chris@18: $reason = $this->getExpectedUnauthorizedAccessMessage('GET'); Chris@18: $message = trim("The current user is not allowed to GET the selected resource. $reason"); Chris@18: $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: $this->assertArrayNotHasKey('Link', $response->getHeaders()); Chris@18: } Chris@18: else { Chris@18: $expected_document = $this->getExpectedDocument(); Chris@18: $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; Chris@18: $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]); Chris@18: unset($expected_document['data']['relationships']); Chris@18: // MISS or UNCACHEABLE depends on data. It must not be HIT. Chris@18: $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), FALSE, $dynamic_cache_label_only); Chris@18: } Chris@18: Chris@18: $this->setUpAuthorization('GET'); Chris@18: Chris@18: // Set body despite that being nonsensical: should be ignored. Chris@18: $request_options[RequestOptions::BODY] = Json::encode($this->getExpectedDocument()); Chris@18: Chris@18: // 400 for GET request with reserved custom query parameter. Chris@18: $url_reserved_custom_query_parameter = clone $url; Chris@18: $url_reserved_custom_query_parameter = $url_reserved_custom_query_parameter->setOption('query', ['foo' => 'bar']); Chris@18: $response = $this->request('GET', $url_reserved_custom_query_parameter, $request_options); Chris@18: $expected_document = [ Chris@18: 'jsonapi' => static::$jsonApiMember, Chris@18: 'errors' => [ Chris@18: [ Chris@18: 'title' => 'Bad Request', Chris@18: 'status' => '400', Chris@18: 'detail' => "The following query parameters violate the JSON:API spec: 'foo'.", Chris@18: 'links' => [ Chris@18: 'info' => ['href' => 'http://jsonapi.org/format/#query-parameters'], Chris@18: 'via' => ['href' => $url_reserved_custom_query_parameter->toString()], Chris@18: ], Chris@18: ], Chris@18: ], Chris@18: ]; Chris@18: $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'MISS'); Chris@18: Chris@18: // 200 for well-formed HEAD request. Chris@18: $response = $this->request('HEAD', $url, $request_options); Chris@18: // MISS or UNCACHEABLE depends on data. It must not be HIT. Chris@18: $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache); Chris@18: $head_headers = $response->getHeaders(); Chris@18: Chris@18: // 200 for well-formed GET request. Page Cache hit because of HEAD request. Chris@18: // Same for Dynamic Page Cache hit. Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: Chris@18: $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE'); Chris@18: // Assert that Dynamic Page Cache did not store a ResourceResponse object, Chris@18: // which needs serialization after every cache hit. Instead, it should Chris@18: // contain a flattened response. Otherwise performance suffers. Chris@18: // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse() Chris@18: $cache_items = $this->container->get('database') Chris@18: ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [ Chris@18: ':pattern' => '%[route]=jsonapi.%', Chris@18: ]) Chris@18: ->fetchAllAssoc('cid'); Chris@18: $this->assertTrue(count($cache_items) >= 2); Chris@18: $found_cache_redirect = FALSE; Chris@18: $found_cached_200_response = FALSE; Chris@18: $other_cached_responses_are_4xx = TRUE; Chris@18: foreach ($cache_items as $cid => $cache_item) { Chris@18: $cached_data = unserialize($cache_item->data); Chris@18: if (!isset($cached_data['#cache_redirect'])) { Chris@18: $cached_response = $cached_data['#response']; Chris@18: if ($cached_response->getStatusCode() === 200) { Chris@18: $found_cached_200_response = TRUE; Chris@18: } Chris@18: elseif (!$cached_response->isClientError()) { Chris@18: $other_cached_responses_are_4xx = FALSE; Chris@18: } Chris@18: $this->assertNotInstanceOf(ResourceResponse::class, $cached_response); Chris@18: $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response); Chris@18: } Chris@18: else { Chris@18: $found_cache_redirect = TRUE; Chris@18: } Chris@18: } Chris@18: $this->assertTrue($found_cache_redirect); Chris@18: $this->assertSame($dynamic_cache !== 'UNCACHEABLE' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE', $found_cached_200_response); Chris@18: $this->assertTrue($other_cached_responses_are_4xx); Chris@18: Chris@18: // Not only assert the normalization, also assert deserialization of the Chris@18: // response results in the expected object. Chris@18: $unserialized = $this->serializer->deserialize((string) $response->getBody(), JsonApiDocumentTopLevel::class, 'api_json', [ Chris@18: 'target_entity' => static::$entityTypeId, Chris@18: 'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName), Chris@18: ]); Chris@18: $this->assertSame($unserialized->uuid(), $this->entity->uuid()); Chris@18: $get_headers = $response->getHeaders(); Chris@18: Chris@18: // Verify that the GET and HEAD responses are the same. The only difference Chris@18: // is that there's no body. For this reason the 'Transfer-Encoding' and Chris@18: // 'Vary' headers are also added to the list of headers to ignore, as they Chris@18: // may be added to GET requests, depending on web server configuration. They Chris@18: // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'. Chris@18: $ignored_headers = [ Chris@18: 'Date', Chris@18: 'Content-Length', Chris@18: 'X-Drupal-Cache', Chris@18: 'X-Drupal-Dynamic-Cache', Chris@18: 'Transfer-Encoding', Chris@18: 'Vary', Chris@18: ]; Chris@18: $header_cleaner = function ($headers) use ($ignored_headers) { Chris@18: foreach ($headers as $header => $value) { Chris@18: if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) { Chris@18: unset($headers[$header]); Chris@18: } Chris@18: } Chris@18: return $headers; Chris@18: }; Chris@18: $get_headers = $header_cleaner($get_headers); Chris@18: $head_headers = $header_cleaner($head_headers); Chris@18: $this->assertSame($get_headers, $head_headers); Chris@18: Chris@18: // Feature: Sparse fieldsets. Chris@18: $this->doTestSparseFieldSets($url, $request_options); Chris@18: // Feature: Included. Chris@18: $this->doTestIncluded($url, $request_options); Chris@18: Chris@18: // DX: 404 when GETting non-existing entity. Chris@18: $random_uuid = \Drupal::service('uuid')->generate(); Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $random_uuid]); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $message_url = clone $url; Chris@18: $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); Chris@18: $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")'; Chris@18: $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE'); Chris@18: Chris@18: // DX: when Accept request header is missing, still 404, same response. Chris@18: unset($request_options[RequestOptions::HEADERS]['Accept']); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE'); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests GETting a collection of resources. Chris@18: */ Chris@18: public function testCollection() { Chris@18: $entity_collection = $this->getData(); Chris@18: assert(count($entity_collection) > 1, 'A collection must have more that one entity in it.'); Chris@18: Chris@18: $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE); Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // This asserts that collections will work without a sort, added by default Chris@18: // below, without actually asserting the content of the response. Chris@18: $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $response = $this->request('HEAD', $collection_url, $request_options); Chris@18: // MISS or UNCACHEABLE depends on the collection data. It must not be HIT. Chris@18: $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: // Different databases have different sort orders, so a sort is required so Chris@18: // test expectations do not need to vary per database. Chris@18: $default_sort = ['sort' => 'drupal_internal__' . $this->entity->getEntityType()->getKey('id')]; Chris@18: $collection_url->setOption('query', $default_sort); Chris@18: Chris@18: // 200 for collections, even when all entities are inaccessible. Access is Chris@18: // on a per-entity basis, which is handled by Chris@18: // self::getExpectedCollectionResponse(). Chris@18: $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $collection_url, $request_options); Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: $this->setUpAuthorization('GET'); Chris@18: Chris@18: // 200 for well-formed HEAD request. Chris@18: $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $response = $this->request('HEAD', $collection_url, $request_options); Chris@18: $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: // 200 for well-formed GET request. Chris@18: $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $collection_url, $request_options); Chris@18: // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE. Chris@18: $dynamic_cache = $dynamic_cache === 'UNCACHEABLE' ? 'UNCACHEABLE' : 'HIT'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: if ($this->entity instanceof FieldableEntityInterface) { Chris@18: // 403 for filtering on an unauthorized field on the base resource type. Chris@18: $unauthorized_filter_url = clone $collection_url; Chris@18: $unauthorized_filter_url->setOption('query', [ Chris@18: 'filter' => [ Chris@18: 'related_author_id' => [ Chris@18: 'operator' => '<>', Chris@18: 'path' => 'field_jsonapi_test_entity_ref.status', Chris@18: 'value' => 'doesnt@matter.com', Chris@18: ], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('GET', $unauthorized_filter_url, $request_options); Chris@18: $expected_error_message = "The current user is not authorized to filter by the `field_jsonapi_test_entity_ref` field, given in the path `field_jsonapi_test_entity_ref`. The 'field_jsonapi_test_entity_ref view access' permission is required."; Chris@18: $expected_cache_tags = ['4xx-response', 'http_response']; Chris@18: $expected_cache_contexts = [ Chris@18: 'url.query_args:filter', Chris@18: 'url.query_args:sort', Chris@18: 'url.site', Chris@18: 'user.permissions', Chris@18: ]; Chris@18: $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: Chris@18: $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']); Chris@18: Chris@18: // 403 for filtering on an unauthorized field on a related resource type. Chris@18: $response = $this->request('GET', $unauthorized_filter_url, $request_options); Chris@18: $expected_error_message = "The current user is not authorized to filter by the `status` field, given in the path `field_jsonapi_test_entity_ref.entity:user.status`."; Chris@18: $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: } Chris@18: Chris@18: // Remove an entity from the collection, then filter it out. Chris@18: $filtered_entity_collection = $entity_collection; Chris@18: $removed = array_shift($filtered_entity_collection); Chris@18: $filtered_collection_url = clone $collection_url; Chris@18: $entity_collection_filter = [ Chris@18: 'filter' => [ Chris@18: 'ids' => [ Chris@18: 'condition' => [ Chris@18: 'operator' => '<>', Chris@18: 'path' => 'id', Chris@18: 'value' => $removed->uuid(), Chris@18: ], Chris@18: ], Chris@18: ], Chris@18: ]; Chris@18: $filtered_collection_url->setOption('query', $entity_collection_filter + $default_sort); Chris@18: $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_url->toString(), $request_options, NULL, TRUE); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $filtered_collection_url, $request_options); Chris@18: // MISS or UNCACHEABLE depends on the collection data. It must not be HIT. Chris@18: $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: // Filtered collection with includes. Chris@18: $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) { Chris@18: return array_unique(array_merge($relationship_field_names, $this->getRelationshipFieldNames($entity))); Chris@18: }, []); Chris@18: $include = ['include' => implode(',', $relationship_field_names)]; Chris@18: $filtered_collection_include_url = clone $collection_url; Chris@18: $filtered_collection_include_url->setOption('query', $entity_collection_filter + $include + $default_sort); Chris@18: $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $filtered_collection_include_url, $request_options); Chris@18: // MISS or UNCACHEABLE depends on the included data. It must not be HIT. Chris@18: $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: Chris@18: // If the response should vary by a user's authorizations, grant permissions Chris@18: // for the included resources and execute another request. Chris@18: $permission_related_cache_contexts = [ Chris@18: 'user', Chris@18: 'user.permissions', Chris@18: 'user.roles', Chris@18: ]; Chris@18: if (!empty($relationship_field_names) && !empty(array_intersect($expected_cacheability->getCacheContexts(), $permission_related_cache_contexts))) { Chris@18: $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($relationship_field_names)); Chris@18: $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', [])); Chris@18: $this->grantPermissionsToTestedRole($flattened_permissions); Chris@18: $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $filtered_collection_include_url, $request_options); Chris@18: $requires_include_only_permissions = !empty($flattened_permissions); Chris@18: $uncacheable = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())); Chris@18: $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: } Chris@18: Chris@18: // Sorted collection with includes. Chris@18: $sorted_entity_collection = $entity_collection; Chris@18: uasort($sorted_entity_collection, function (EntityInterface $a, EntityInterface $b) { Chris@18: // Sort by ID in reverse order. Chris@18: return strcmp($b->uuid(), $a->uuid()); Chris@18: }); Chris@18: $sorted_collection_include_url = clone $collection_url; Chris@18: $sorted_collection_include_url->setOption('query', $include + ['sort' => "-id"]); Chris@18: $expected_response = $this->getExpectedCollectionResponse($sorted_entity_collection, $sorted_collection_include_url->toString(), $request_options, $relationship_field_names); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $response = $this->request('GET', $sorted_collection_include_url, $request_options); Chris@18: // MISS or UNCACHEABLE depends on the included data. It must not be HIT. Chris@18: $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Returns a JSON:API collection document for the expected entities. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface[] $collection Chris@18: * The entities for the collection. Chris@18: * @param string $self_link Chris@18: * The self link for the collection response document. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * @param array|null $included_paths Chris@18: * (optional) Any include paths that should be appended to the expected Chris@18: * response. Chris@18: * @param bool $filtered Chris@18: * Whether the collection is filtered or not. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * A ResourceResponse for the expected entity collection. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getExpectedCollectionResponse(array $collection, $self_link, array $request_options, array $included_paths = NULL, $filtered = FALSE) { Chris@18: $resource_identifiers = array_map([static::class, 'toResourceIdentifier'], $collection); Chris@18: $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options)); Chris@18: $merged_response = static::toCollectionResourceResponse($individual_responses, $self_link, TRUE); Chris@18: Chris@18: $merged_document = $merged_response->getResponseData(); Chris@18: if (!isset($merged_document['data'])) { Chris@18: $merged_document['data'] = []; Chris@18: } Chris@18: Chris@18: $cacheability = static::getExpectedCollectionCacheability($this->account, $collection, NULL, $filtered); Chris@18: $cacheability->setCacheMaxAge($merged_response->getCacheableMetadata()->getCacheMaxAge()); Chris@18: Chris@18: $collection_response = ResourceResponse::create($merged_document); Chris@18: $collection_response->addCacheableDependency($cacheability); Chris@18: Chris@18: if (is_null($included_paths)) { Chris@18: return $collection_response; Chris@18: } Chris@18: Chris@18: $related_responses = array_reduce($collection, function ($related_responses, EntityInterface $entity) use ($included_paths, $request_options, $self_link) { Chris@18: if (!$entity->access('view', $this->account) && !$entity->access('view label', $this->account)) { Chris@18: return $related_responses; Chris@18: } Chris@18: $expected_related_responses = $this->getExpectedRelatedResponses($included_paths, $request_options, $entity); Chris@18: if (empty($related_responses)) { Chris@18: return $expected_related_responses; Chris@18: } Chris@18: foreach ($included_paths as $included_path) { Chris@18: $both_responses = [$related_responses[$included_path], $expected_related_responses[$included_path]]; Chris@18: $related_responses[$included_path] = static::toCollectionResourceResponse($both_responses, $self_link, TRUE); Chris@18: } Chris@18: return $related_responses; Chris@18: }, []); Chris@18: Chris@18: return static::decorateExpectedResponseForIncludedFields($collection_response, $related_responses); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests GETing related resource of an individual resource. Chris@18: * Chris@18: * Expected responses are built by making requests to 'relationship' routes. Chris@18: * Using the fetched resource identifiers, if any, all targeted resources are Chris@18: * fetched individually. These individual responses are then 'merged' into a Chris@18: * single expected ResourceResponse. This is repeated for every relationship Chris@18: * field of the resource type under test. Chris@18: */ Chris@18: public function testRelated() { Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: $this->doTestRelated($request_options); Chris@18: $this->setUpAuthorization('GET'); Chris@18: $this->doTestRelated($request_options); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests CRUD of individual resource relationship data. Chris@18: * Chris@18: * Unlike the "related" routes, relationship routes only return information Chris@18: * about the "relationship" itself, not the targeted resources. For JSON:API Chris@18: * with Drupal, relationship routes are like looking at an entity reference Chris@18: * field without loading the entities. It only reveals the type of the Chris@18: * targeted resource and the target resource IDs. These type+ID combos are Chris@18: * referred to as "resource identifiers." Chris@18: */ Chris@18: public function testRelationships() { Chris@18: if ($this->entity instanceof ConfigEntityInterface) { Chris@18: $this->markTestSkipped('Configuration entities cannot have relationships.'); Chris@18: } Chris@18: Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // Test GET. Chris@18: $this->doTestRelationshipGet($request_options); Chris@18: $this->setUpAuthorization('GET'); Chris@18: $this->doTestRelationshipGet($request_options); Chris@18: Chris@18: // Test POST. Chris@18: $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); Chris@18: $this->doTestRelationshipMutation($request_options); Chris@18: // Grant entity-level edit access. Chris@18: $this->setUpAuthorization('PATCH'); Chris@18: $this->doTestRelationshipMutation($request_options); Chris@18: // Field edit access is still forbidden, grant it. Chris@18: $this->grantPermissionsToTestedRole([ Chris@18: 'field_jsonapi_test_entity_ref view access', Chris@18: 'field_jsonapi_test_entity_ref edit access', Chris@18: 'field_jsonapi_test_entity_ref update access', Chris@18: ]); Chris@18: $this->doTestRelationshipMutation($request_options); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Performs one round of related route testing. Chris@18: * Chris@18: * By putting this behavior in its own method, authorization and other Chris@18: * variations can be done in the calling method around assertions. For Chris@18: * example, it can be run once with an authorized user and again without one. Chris@18: * Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function doTestRelated(array $request_options) { Chris@18: $relationship_field_names = $this->getRelationshipFieldNames($this->entity); Chris@18: // If there are no relationship fields, we can't test related routes. Chris@18: if (empty($relationship_field_names)) { Chris@18: return; Chris@18: } Chris@18: // Builds an array of expected responses, keyed by relationship field name. Chris@18: $expected_relationship_responses = $this->getExpectedRelatedResponses($relationship_field_names, $request_options); Chris@18: // Fetches actual responses as an array keyed by relationship field name. Chris@18: $related_responses = $this->getRelatedResponses($relationship_field_names, $request_options); Chris@18: foreach ($relationship_field_names as $relationship_field_name) { Chris@18: /* @var \Drupal\jsonapi\ResourceResponse $expected_resource_response */ Chris@18: $expected_resource_response = $expected_relationship_responses[$relationship_field_name]; Chris@18: /* @var \Psr\Http\Message\ResponseInterface $actual_response */ Chris@18: $actual_response = $related_responses[$relationship_field_name]; Chris@18: // Dynamic Page Cache miss because cache should vary based on the Chris@18: // 'include' query param. Chris@18: $expected_cacheability = $expected_resource_response->getCacheableMetadata(); Chris@18: $this->assertResourceResponse( Chris@18: $expected_resource_response->getStatusCode(), Chris@18: $expected_resource_response->getResponseData(), Chris@18: $actual_response, Chris@18: $expected_cacheability->getCacheTags(), Chris@18: $expected_cacheability->getCacheContexts(), Chris@18: FALSE, Chris@18: $actual_response->getStatusCode() === 200 Chris@18: ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS') Chris@18: : FALSE Chris@18: ); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Performs one round of relationship route testing. Chris@18: * Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: * @see ::testRelationships Chris@18: */ Chris@18: protected function doTestRelationshipGet(array $request_options) { Chris@18: $relationship_field_names = $this->getRelationshipFieldNames($this->entity); Chris@18: // If there are no relationship fields, we can't test relationship routes. Chris@18: if (empty($relationship_field_names)) { Chris@18: return; Chris@18: } Chris@18: Chris@18: // Test GET. Chris@18: $related_responses = $this->getRelationshipResponses($relationship_field_names, $request_options); Chris@18: foreach ($relationship_field_names as $relationship_field_name) { Chris@18: $expected_resource_response = $this->getExpectedGetRelationshipResponse($relationship_field_name); Chris@18: $expected_document = $expected_resource_response->getResponseData(); Chris@18: $expected_cacheability = $expected_resource_response->getCacheableMetadata(); Chris@18: $actual_response = $related_responses[$relationship_field_name]; Chris@18: $this->assertResourceResponse( Chris@18: $expected_resource_response->getStatusCode(), Chris@18: $expected_document, Chris@18: $actual_response, Chris@18: $expected_cacheability->getCacheTags(), Chris@18: $expected_cacheability->getCacheContexts(), Chris@18: FALSE, Chris@18: $expected_resource_response->isSuccessful() ? 'MISS' : FALSE Chris@18: ); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Performs one round of relationship POST, PATCH and DELETE route testing. Chris@18: * Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: * @see ::testRelationships Chris@18: */ Chris@18: protected function doTestRelationshipMutation(array $request_options) { Chris@18: /* @var \Drupal\Core\Entity\FieldableEntityInterface $resource */ Chris@18: $resource = $this->createAnotherEntity('dupe'); Chris@18: $resource->set('field_jsonapi_test_entity_ref', NULL); Chris@18: $violations = $resource->validate(); Chris@18: assert($violations->count() === 0, (string) $violations); Chris@18: $resource->save(); Chris@18: $target_resource = $this->createUser(); Chris@18: $violations = $target_resource->validate(); Chris@18: assert($violations->count() === 0, (string) $violations); Chris@18: $target_resource->save(); Chris@18: $target_identifier = static::toResourceIdentifier($target_resource); Chris@18: $resource_identifier = static::toResourceIdentifier($resource); Chris@18: $relationship_field_name = 'field_jsonapi_test_entity_ref'; Chris@18: /* @var \Drupal\Core\Access\AccessResultReasonInterface $update_access */ Chris@18: $update_access = static::entityAccess($resource, 'update', $this->account) Chris@18: ->andIf(static::entityFieldAccess($resource, $relationship_field_name, 'edit', $this->account)); Chris@18: $url = Url::fromRoute(sprintf("jsonapi.{$resource_identifier['type']}.{$relationship_field_name}.relationship.patch"), [ Chris@18: 'entity' => $resource->uuid(), Chris@18: ]); Chris@18: Chris@18: // Test POST: missing content-type. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertSame(415, $response->getStatusCode()); Chris@18: Chris@18: // Set the JSON:API media type header for all subsequent requests. Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: Chris@18: if ($update_access->isAllowed()) { Chris@18: // Test POST: empty body. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); Chris@18: // Test PATCH: empty body. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); Chris@18: Chris@18: // Test POST: empty data. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => []]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: // Test PATCH: empty data. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => []]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // Test POST: data as resource identifier, not array of identifiers. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); Chris@18: // Test PATCH: data as resource identifier, not array of identifiers. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); Chris@18: Chris@18: // Test POST: missing the 'type' field. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); Chris@18: // Test PATCH: missing the 'type' field. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); Chris@18: Chris@18: // If the base resource type is the same as that of the target's (as it Chris@18: // will be for `user--user`), then the validity error will not be Chris@18: // triggered, needlessly failing this assertion. Chris@18: if (static::$resourceTypeName !== $target_identifier['type']) { Chris@18: // Test POST: invalid target. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE); Chris@18: // Test PATCH: invalid target. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE); Chris@18: } Chris@18: Chris@18: // Test POST: duplicate targets, no arity. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE); Chris@18: Chris@18: // Test PATCH: duplicate targets, no arity. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE); Chris@18: Chris@18: // Test POST: success. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // Test POST: success, relationship already exists, no arity. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // Test POST: success, relationship already exists, new arity. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier + ['meta' => ['arity' => 1]]]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $resource->set($relationship_field_name, [$target_resource, $target_resource]); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $expected_document['data'][0] += ['meta' => ['arity' => 0]]; Chris@18: $expected_document['data'][1] += ['meta' => ['arity' => 1]]; Chris@18: $this->assertResourceResponse(200, $expected_document, $response); Chris@18: Chris@18: // Test PATCH: success, new value is the same as given value. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 0]], Chris@18: $target_identifier + ['meta' => ['arity' => 1]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // Test POST: success, relationship already exists, new arity. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 2]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $resource->set($relationship_field_name, [ Chris@18: $target_resource, Chris@18: $target_resource, Chris@18: $target_resource, Chris@18: ]); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $expected_document['data'][0] += ['meta' => ['arity' => 0]]; Chris@18: $expected_document['data'][1] += ['meta' => ['arity' => 1]]; Chris@18: $expected_document['data'][2] += ['meta' => ['arity' => 2]]; Chris@18: // 200 with response body because the request did not include the Chris@18: // existing relationship resource identifier object. Chris@18: $this->assertResourceResponse(200, $expected_document, $response); Chris@18: Chris@18: // Test POST: success. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 0]], Chris@18: $target_identifier + ['meta' => ['arity' => 1]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: // 200 with response body because the request did not include the Chris@18: // resource identifier with arity 2. Chris@18: $this->assertResourceResponse(200, $expected_document, $response); Chris@18: Chris@18: // Test PATCH: success. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 0]], Chris@18: $target_identifier + ['meta' => ['arity' => 1]], Chris@18: $target_identifier + ['meta' => ['arity' => 2]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: // 204 no content. PATCH data matches existing data. Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // Test DELETE: three existing relationships, two removed. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 0]], Chris@18: $target_identifier + ['meta' => ['arity' => 2]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: // Subsequent GET should return only one resource identifier, with no Chris@18: // arity. Chris@18: $resource->set($relationship_field_name, [$target_resource]); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); Chris@18: Chris@18: // Test DELETE: one existing relationship, removed. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $resource->set($relationship_field_name, []); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); Chris@18: Chris@18: // Test DELETE: no existing relationships, no op, success. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); Chris@18: Chris@18: // Test PATCH: success, new value is different than existing value. Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: $target_identifier + ['meta' => ['arity' => 2]], Chris@18: $target_identifier + ['meta' => ['arity' => 3]], Chris@18: ], Chris@18: ]); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $resource->set($relationship_field_name, [$target_resource, $target_resource]); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $expected_document['data'][0] += ['meta' => ['arity' => 0]]; Chris@18: $expected_document['data'][1] += ['meta' => ['arity' => 1]]; Chris@18: // 200 with response body because arity values are computed; that means Chris@18: // that the PATCH arity values 2 + 3 will become 0 + 1 if there are not Chris@18: // already resource identifiers with those arity values. Chris@18: $this->assertResourceResponse(200, $expected_document, $response); Chris@18: Chris@18: // Test DELETE: two existing relationships, both removed because no arity Chris@18: // was specified. Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $resource->set($relationship_field_name, []); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: $resource->set($relationship_field_name, []); Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); Chris@18: } Chris@18: else { Chris@18: $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $message = 'The current user is not allowed to edit this relationship.'; Chris@18: $message .= ($reason = $update_access->getReason()) ? ' ' . $reason : ''; Chris@18: $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); Chris@18: } Chris@18: Chris@18: // Remove the test entities that were created. Chris@18: $resource->delete(); Chris@18: $target_resource->delete(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an expected ResourceResponse for the given relationship. Chris@18: * Chris@18: * @param string $relationship_field_name Chris@18: * The relationship for which to get an expected response. Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get expected relationship response. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The expected ResourceResponse. Chris@18: */ Chris@18: protected function getExpectedGetRelationshipResponse($relationship_field_name, EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType->getInternalName($relationship_field_name), 'view', $this->account)); Chris@18: if (!$access->isAllowed()) { Chris@18: $via_link = Url::fromRoute( Chris@18: sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, $relationship_field_name), Chris@18: ['entity' => $entity->uuid()] Chris@18: ); Chris@18: return static::getAccessDeniedResponse($this->entity, $access, $via_link, $relationship_field_name, 'The current user is not allowed to view this relationship.', FALSE); Chris@18: } Chris@18: $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $entity); Chris@18: $expected_cacheability = (new CacheableMetadata()) Chris@18: ->addCacheTags(['http_response']) Chris@18: ->addCacheContexts([ Chris@18: 'url.site', Chris@18: 'url.query_args:include', Chris@18: 'url.query_args:fields', Chris@18: ]) Chris@18: ->addCacheableDependency($entity) Chris@18: ->addCacheableDependency($access); Chris@18: $status_code = isset($expected_document['errors'][0]['status']) ? $expected_document['errors'][0]['status'] : 200; Chris@18: $resource_response = new ResourceResponse($expected_document, $status_code); Chris@18: $resource_response->addCacheableDependency($expected_cacheability); Chris@18: return $resource_response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an expected document for the given relationship. Chris@18: * Chris@18: * @param string $relationship_field_name Chris@18: * The relationship for which to get an expected response. Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get expected relationship document. Chris@18: * Chris@18: * @return array Chris@18: * The expected document array. Chris@18: */ Chris@18: protected function getExpectedGetRelationshipDocument($relationship_field_name, EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: $entity_type_id = $entity->getEntityTypeId(); Chris@18: $bundle = $entity->bundle(); Chris@18: $id = $entity->uuid(); Chris@18: $self_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/relationships/$relationship_field_name")->setAbsolute(); Chris@18: $related_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/$relationship_field_name")->setAbsolute(); Chris@18: if (static::$resourceTypeIsVersionable) { Chris@18: assert($entity instanceof RevisionableInterface); Chris@18: $version_query = ['resourceVersion' => 'id:' . $entity->getRevisionId()]; Chris@18: $self_link->setOption('query', $version_query); Chris@18: $related_link->setOption('query', $version_query); Chris@18: } Chris@18: $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity); Chris@18: return [ Chris@18: 'data' => $data, Chris@18: 'jsonapi' => static::$jsonApiMember, Chris@18: 'links' => [ Chris@18: 'self' => ['href' => $self_link->toString(TRUE)->getGeneratedUrl()], Chris@18: 'related' => ['href' => $related_link->toString(TRUE)->getGeneratedUrl()], Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the expected document data for the given relationship. Chris@18: * Chris@18: * @param string $relationship_field_name Chris@18: * The relationship for which to get an expected response. Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get expected relationship data. Chris@18: * Chris@18: * @return mixed Chris@18: * The expected document data. Chris@18: */ Chris@18: protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: $internal_field_name = $this->resourceType->getInternalName($relationship_field_name); Chris@18: /* @var \Drupal\Core\Field\FieldItemListInterface $field */ Chris@18: $field = $entity->{$internal_field_name}; Chris@18: $is_multiple = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() !== 1; Chris@18: if ($field->isEmpty()) { Chris@18: return $is_multiple ? [] : NULL; Chris@18: } Chris@18: if (!$is_multiple) { Chris@18: $target_entity = $field->entity; Chris@18: return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity); Chris@18: } Chris@18: else { Chris@18: return array_filter(array_map(function ($item) { Chris@18: $target_entity = $item->entity; Chris@18: return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity); Chris@18: }, iterator_to_array($field))); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Builds an array of expected related ResourceResponses, keyed by field name. Chris@18: * Chris@18: * @param array $relationship_field_names Chris@18: * The relationship field names for which to build expected Chris@18: * ResourceResponses. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get expected related resources. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse[] Chris@18: * An array of expected ResourceResponses, keyed by their relationship field Chris@18: * name. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: return array_map(function ($relationship_field_name) use ($entity, $request_options) { Chris@18: return $this->getExpectedRelatedResponse($relationship_field_name, $request_options, $entity); Chris@18: }, array_combine($relationship_field_names, $relationship_field_names)); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Builds an expected related ResourceResponse for the given field. Chris@18: * Chris@18: * @param string $relationship_field_name Chris@18: * The relationship field name for which to build an expected Chris@18: * ResourceResponse. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity for which to get expected related resources. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * An expected ResourceResponse. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getExpectedRelatedResponse($relationship_field_name, array $request_options, EntityInterface $entity) { Chris@18: // Get the relationships responses which contain resource identifiers for Chris@18: // every related resource. Chris@18: /* @var \Drupal\jsonapi\ResourceResponse[] $relationship_responses */ Chris@18: $base_resource_identifier = static::toResourceIdentifier($entity); Chris@18: $internal_name = $this->resourceType->getInternalName($relationship_field_name); Chris@18: $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account)); Chris@18: if (!$access->isAllowed()) { Chris@18: $detail = 'The current user is not allowed to view this relationship.'; Chris@18: if (!$entity->access('view') && $entity->access('view label') && $access instanceof AccessResultReasonInterface && empty($access->getReason())) { Chris@18: $access->setReason("The user only has authorization for the 'view label' operation."); Chris@18: } Chris@18: $via_link = Url::fromRoute( Chris@18: sprintf('jsonapi.%s.%s.related', $base_resource_identifier['type'], $relationship_field_name), Chris@18: ['entity' => $base_resource_identifier['id']] Chris@18: ); Chris@18: $related_response = static::getAccessDeniedResponse($entity, $access, $via_link, $relationship_field_name, $detail, FALSE); Chris@18: } Chris@18: else { Chris@18: $self_link = static::getRelatedLink($base_resource_identifier, $relationship_field_name); Chris@18: $relationship_response = $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity); Chris@18: $relationship_document = $relationship_response->getResponseData(); Chris@18: // The relationships may be empty, in which case we shouldn't attempt to Chris@18: // fetch the individual identified resources. Chris@18: if (empty($relationship_document['data'])) { Chris@18: $cache_contexts = Cache::mergeContexts([ Chris@18: // Cache contexts for JSON:API URL query parameters. Chris@18: 'url.query_args:fields', Chris@18: 'url.query_args:include', Chris@18: // Drupal defaults. Chris@18: 'url.site', Chris@18: ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']); Chris@18: $related_response = isset($relationship_document['errors']) Chris@18: ? $relationship_response Chris@18: : (new ResourceResponse(static::getEmptyCollectionResponse(!is_null($relationship_document['data']), $self_link)->getResponseData()))->addCacheableDependency($cacheability); Chris@18: } Chris@18: else { Chris@18: $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']); Chris@18: $resource_identifiers = $is_to_one_relationship Chris@18: ? [$relationship_document['data']] Chris@18: : $relationship_document['data']; Chris@18: // Remove any relationships to 'virtual' resources. Chris@18: $resource_identifiers = array_filter($resource_identifiers, function ($resource_identifier) { Chris@18: return $resource_identifier['id'] !== 'virtual'; Chris@18: }); Chris@18: if (!empty($resource_identifiers)) { Chris@18: $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options)); Chris@18: $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship); Chris@18: } Chris@18: else { Chris@18: $related_response = static::getEmptyCollectionResponse(!$is_to_one_relationship, $self_link); Chris@18: } Chris@18: } Chris@18: $related_response->addCacheableDependency($relationship_response->getCacheableMetadata()); Chris@18: } Chris@18: return $related_response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests POSTing an individual resource, plus edge cases to ensure good DX. Chris@18: */ Chris@18: public function testPostIndividual() { Chris@18: // @todo Remove this in https://www.drupal.org/node/2300677. Chris@18: if ($this->entity instanceof ConfigEntityInterface) { Chris@18: $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); Chris@18: return; Chris@18: } Chris@18: Chris@18: // Try with all of the following request bodies. Chris@18: $unparseable_request_body = '!{>}<'; Chris@18: $parseable_valid_request_body = Json::encode($this->getPostDocument()); Chris@18: /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */ Chris@18: $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type')); Chris@18: $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); Chris@18: $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument())); Chris@18: $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument())); Chris@18: $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument())); Chris@18: Chris@18: // The URL and Guzzle request options that will be used in this test. The Chris@18: // request options will be modified/expanded throughout this test: Chris@18: // - to first test all mistakes a developer might make, and assert that the Chris@18: // error responses provide a good DX Chris@18: // - to eventually result in a well-formed request that succeeds. Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName)); Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // DX: 405 when read-only mode is enabled. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); Chris@18: if ($this->resourceType->isLocatable()) { Chris@18: $this->assertSame(['GET'], $response->getHeader('Allow')); Chris@18: } Chris@18: else { Chris@18: $this->assertSame([''], $response->getHeader('Allow')); Chris@18: } Chris@18: Chris@18: $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); Chris@18: Chris@18: // DX: 415 when no Content-Type request header. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertSame(415, $response->getStatusCode()); Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: Chris@18: // DX: 403 when unauthorized. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $reason = $this->getExpectedUnauthorizedAccessMessage('POST'); Chris@18: $this->assertResourceErrorResponse(403, (string) $reason, $url, $response); Chris@18: Chris@18: $this->setUpAuthorization('POST'); Chris@18: Chris@18: // DX: 400 when no request body. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $unparseable_request_body; Chris@18: Chris@18: // DX: 400 when unparseable request body. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type; Chris@18: Chris@18: // DX: 400 when invalid JSON:API request body. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; Chris@18: Chris@18: // DX: 422 when invalid entity: multiple values sent for single-value field. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; Chris@18: $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); Chris@18: $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; Chris@18: Chris@18: // DX: 403 when invalid entity: UUID field too long. Chris@18: // @todo Fix this in https://www.drupal.org/project/drupal/issues/2149851. Chris@18: if ($this->entity->getEntityType()->hasKey('uuid')) { Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(422, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $url, $response); Chris@18: } Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; Chris@18: Chris@18: // DX: 403 when entity contains field without 'edit' access. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, "The current user is not allowed to POST the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4; Chris@18: Chris@18: // DX: 422 when request document contains non-existent field. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_valid_request_body; Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; Chris@18: Chris@18: // DX: 415 when request body in existing but not allowed format. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $url, $response); Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: Chris@18: // 201 for well-formed request. Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(201, FALSE, $response); Chris@18: $this->assertFalse($response->hasHeader('X-Drupal-Cache')); Chris@18: // If the entity is stored, perform extra checks. Chris@18: if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { Chris@18: $created_entity = $this->entityLoadUnchanged(static::$firstCreatedEntityId); Chris@18: $uuid = $created_entity->uuid(); Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]); Chris@18: if (static::$resourceTypeIsVersionable) { Chris@18: assert($created_entity instanceof RevisionableInterface); Chris@18: $location->setOption('query', ['resourceVersion' => 'id:' . $created_entity->getRevisionId()]); Chris@18: } Chris@18: /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */ Chris@18: $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location')); Chris@18: Chris@18: // Assert that the entity was indeed created, and that the response body Chris@18: // contains the serialized created entity. Chris@18: $created_entity_document = $this->normalize($created_entity, $url); Chris@18: $decoded_response_body = Json::decode((string) $response->getBody()); Chris@18: $this->assertSame($created_entity_document, $decoded_response_body); Chris@18: // Assert that the entity was indeed created using the POSTed values. Chris@18: foreach ($this->getPostDocument()['data']['attributes'] as $field_name => $field_normalization) { Chris@18: // If the value is an array of properties, only verify that the sent Chris@18: // properties are present, the server could be computing additional Chris@18: // properties. Chris@18: if (is_array($field_normalization)) { Chris@18: $this->assertArraySubset($field_normalization, $created_entity_document['data']['attributes'][$field_name]); Chris@18: } Chris@18: else { Chris@18: $this->assertSame($field_normalization, $created_entity_document['data']['attributes'][$field_name]); Chris@18: } Chris@18: } Chris@18: if (isset($this->getPostDocument()['data']['relationships'])) { Chris@18: foreach ($this->getPostDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) { Chris@18: // POSTing relationships: 'data' is required, 'links' is optional. Chris@18: static::recursiveKsort($relationship_field_normalization); Chris@18: static::recursiveKsort($created_entity_document['data']['relationships'][$field_name]); Chris@18: $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], ['links' => TRUE])); Chris@18: } Chris@18: } Chris@18: } Chris@18: else { Chris@18: $this->assertFalse($response->hasHeader('Location')); Chris@18: } Chris@18: Chris@18: // 201 for well-formed request that creates another entity. Chris@18: // If the entity is stored, delete the first created entity (in case there Chris@18: // is a uniqueness constraint). Chris@18: if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { Chris@18: $this->entityStorage->load(static::$firstCreatedEntityId)->delete(); Chris@18: } Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(201, FALSE, $response); Chris@18: $this->assertFalse($response->hasHeader('X-Drupal-Cache')); Chris@18: Chris@18: if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) { Chris@18: $second_created_entity = $this->entityStorage->load(static::$secondCreatedEntityId); Chris@18: $uuid = $second_created_entity->uuid(); Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]); Chris@18: /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */ Chris@18: if (static::$resourceTypeIsVersionable) { Chris@18: assert($created_entity instanceof RevisionableInterface); Chris@18: $location->setOption('query', ['resourceVersion' => 'id:' . $second_created_entity->getRevisionId()]); Chris@18: } Chris@18: $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location')); Chris@18: Chris@18: // 500 when creating an entity with a duplicate UUID. Chris@18: $doc = $this->getModifiedEntityForPostTesting(); Chris@18: $doc['data']['id'] = $uuid; Chris@18: $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; Chris@18: $request_options[RequestOptions::BODY] = Json::encode($doc); Chris@18: Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(409, 'Conflict: Entity already exists.', $url, $response, FALSE); Chris@18: Chris@18: // 201 when successfully creating an entity with a new UUID. Chris@18: $doc = $this->getModifiedEntityForPostTesting(); Chris@18: $new_uuid = \Drupal::service('uuid')->generate(); Chris@18: $doc['data']['id'] = $new_uuid; Chris@18: $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; Chris@18: $request_options[RequestOptions::BODY] = Json::encode($doc); Chris@18: Chris@18: $response = $this->request('POST', $url, $request_options); Chris@18: $this->assertResourceResponse(201, FALSE, $response); Chris@18: $entities = $this->entityStorage->loadByProperties([$this->uuidKey => $new_uuid]); Chris@18: $new_entity = reset($entities); Chris@18: $this->assertNotNull($new_entity); Chris@18: $new_entity->delete(); Chris@18: } Chris@18: else { Chris@18: $this->assertFalse($response->hasHeader('Location')); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests PATCHing an individual resource, plus edge cases to ensure good DX. Chris@18: */ Chris@18: public function testPatchIndividual() { Chris@18: // @todo Remove this in https://www.drupal.org/node/2300677. Chris@18: if ($this->entity instanceof ConfigEntityInterface) { Chris@18: $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.'); Chris@18: return; Chris@18: } Chris@18: Chris@18: $prior_revision_id = (int) $this->entityLoadUnchanged($this->entity->id())->getRevisionId(); Chris@18: Chris@18: // Patch testing requires that another entity of the same type exists. Chris@18: $this->anotherEntity = $this->createAnotherEntity('dupe'); Chris@18: Chris@18: // Try with all of the following request bodies. Chris@18: $unparseable_request_body = '!{>}<'; Chris@18: $parseable_valid_request_body = Json::encode($this->getPatchDocument()); Chris@18: /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */ Chris@18: $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label')); Chris@18: $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPatchDocument())); Chris@18: // The 'field_rest_test' field does not allow 'view' access, so does not end Chris@18: // up in the JSON:API document. Even when we explicitly add it to the JSON Chris@18: // API document that we send in a PATCH request, it is considered invalid. Chris@18: $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()]]], $this->getPatchDocument())); Chris@18: $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPatchDocument())); Chris@18: // It is invalid to PATCH a relationship field under the attributes member. Chris@18: if ($this->entity instanceof FieldableEntityInterface && $this->entity->hasField('field_jsonapi_test_entity_ref')) { Chris@18: $parseable_invalid_request_body_5 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_jsonapi_test_entity_ref' => ['target_id' => $this->randomString()]]]], $this->getPostDocument())); Chris@18: } Chris@18: Chris@18: // The URL and Guzzle request options that will be used in this test. The Chris@18: // request options will be modified/expanded throughout this test: Chris@18: // - to first test all mistakes a developer might make, and assert that the Chris@18: // error responses provide a good DX Chris@18: // - to eventually result in a well-formed request that succeeds. Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); Chris@18: /* $url = $this->entity->toUrl('jsonapi'); */ Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // DX: 405 when read-only mode is enabled. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); Chris@18: $this->assertSame(['GET'], $response->getHeader('Allow')); Chris@18: Chris@18: $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); Chris@18: Chris@18: // DX: 415 when no Content-Type request header. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertsame(415, $response->getStatusCode()); Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: Chris@18: // DX: 403 when unauthorized. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH'); Chris@18: $this->assertResourceErrorResponse(403, (string) $reason, $url, $response); Chris@18: Chris@18: $this->setUpAuthorization('PATCH'); Chris@18: Chris@18: // DX: 400 when no request body. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $unparseable_request_body; Chris@18: Chris@18: // DX: 400 when unparseable request body. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; Chris@18: Chris@18: // DX: 422 when invalid entity: multiple values sent for single-value field. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; Chris@18: $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); Chris@18: $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; Chris@18: Chris@18: // DX: 403 when entity contains field without 'edit' access. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); Chris@18: Chris@18: // DX: 403 when entity trying to update an entity's ID field. Chris@18: $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'id')); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $id_field_name = $this->entity->getEntityType()->getKey('id'); Chris@18: $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ($id_field_name). The entity ID cannot be changed.", $url, $response, "/data/attributes/$id_field_name"); Chris@18: Chris@18: if ($this->entity->getEntityType()->hasKey('uuid')) { Chris@18: // DX: 400 when entity trying to update an entity's UUID field. Chris@18: $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'uuid')); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf("The selected entity (%s) does not match the ID in the payload (%s).", $this->entity->uuid(), $this->anotherEntity->uuid()), $url, $response, FALSE); Chris@18: } Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; Chris@18: Chris@18: // DX: 403 when entity contains field without 'edit' nor 'view' access, even Chris@18: // when the value for that field matches the current value. This is allowed Chris@18: // in principle, but leads to information disclosure. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); Chris@18: Chris@18: // DX: 403 when sending PATCH request with updated read-only fields. Chris@18: list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity); Chris@18: // Send PATCH request by serializing the modified entity, assert the error Chris@18: // response, change the modified entity field that caused the error response Chris@18: // back to its original value, repeat. Chris@18: foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) { Chris@18: $request_options[RequestOptions::BODY] = Json::encode($this->normalize($modified_entity, $url)); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $url->setAbsolute(), $response, '/data/attributes/' . $patch_protected_field_name); Chris@18: $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]); Chris@18: } Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4; Chris@18: Chris@18: // DX: 422 when request document contains non-existent field. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE); Chris@18: Chris@18: // DX: 422 when updating a relationship field under attributes. Chris@18: if (isset($parseable_invalid_request_body_5)) { Chris@18: $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_5; Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(422, "The following relationship fields were provided as attributes: [ field_jsonapi_test_entity_ref ]", $url, $response, FALSE); Chris@18: } Chris@18: Chris@18: // 200 for well-formed PATCH request that sends all fields (even including Chris@18: // read-only ones, but with unchanged values). Chris@18: $valid_request_body = NestedArray::mergeDeep($this->normalize($this->entity, $url), $this->getPatchDocument()); Chris@18: $request_options[RequestOptions::BODY] = Json::encode($valid_request_body); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response); Chris@18: $updated_entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); Chris@18: $prior_revision_id = (int) $updated_entity->getRevisionId(); Chris@18: Chris@18: $request_options[RequestOptions::BODY] = $parseable_valid_request_body; Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; Chris@18: Chris@18: // DX: 415 when request body in existing but not allowed format. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertSame(415, $response->getStatusCode()); Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: Chris@18: // 200 for well-formed request. Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response); Chris@18: $this->assertFalse($response->hasHeader('X-Drupal-Cache')); Chris@18: // Assert that the entity was indeed updated, and that the response body Chris@18: // contains the serialized updated entity. Chris@18: $updated_entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); Chris@18: if ($this->entity instanceof RevisionLogInterface) { Chris@18: if (static::$newRevisionsShouldBeAutomatic) { Chris@18: $this->assertNotSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime()); Chris@18: } Chris@18: else { Chris@18: $this->assertSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime()); Chris@18: } Chris@18: } Chris@18: $updated_entity_document = $this->normalize($updated_entity, $url); Chris@18: $this->assertSame($updated_entity_document, Json::decode((string) $response->getBody())); Chris@18: $prior_revision_id = (int) $updated_entity->getRevisionId(); Chris@18: // Assert that the entity was indeed created using the PATCHed values. Chris@18: foreach ($this->getPatchDocument()['data']['attributes'] as $field_name => $field_normalization) { Chris@18: // If the value is an array of properties, only verify that the sent Chris@18: // properties are present, the server could be computing additional Chris@18: // properties. Chris@18: if (is_array($field_normalization)) { Chris@18: $this->assertArraySubset($field_normalization, $updated_entity_document['data']['attributes'][$field_name]); Chris@18: } Chris@18: else { Chris@18: $this->assertSame($field_normalization, $updated_entity_document['data']['attributes'][$field_name]); Chris@18: } Chris@18: } Chris@18: if (isset($this->getPatchDocument()['data']['relationships'])) { Chris@18: foreach ($this->getPatchDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) { Chris@18: // POSTing relationships: 'data' is required, 'links' is optional. Chris@18: static::recursiveKsort($relationship_field_normalization); Chris@18: static::recursiveKsort($updated_entity_document['data']['relationships'][$field_name]); Chris@18: $this->assertSame($relationship_field_normalization, array_diff_key($updated_entity_document['data']['relationships'][$field_name], ['links' => TRUE])); Chris@18: } Chris@18: } Chris@18: Chris@18: // Ensure that fields do not get deleted if they're not present in the PATCH Chris@18: // request. Test this using the configurable field that we added, but which Chris@18: // is not sent in the PATCH request. Chris@18: $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value); Chris@18: Chris@18: // Multi-value field: remove item 0. Then item 1 becomes item 0. Chris@18: $doc_multi_value_tests = $this->getPatchDocument(); Chris@18: $doc_multi_value_tests['data']['attributes']['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue(); Chris@18: $doc_remove_item = $doc_multi_value_tests; Chris@18: unset($doc_remove_item['data']['attributes']['field_rest_test_multivalue'][0]); Chris@18: $request_options[RequestOptions::BODY] = Json::encode($doc_remove_item, 'api_json'); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response); Chris@18: $updated_entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: $this->assertSame([0 => ['value' => 'Two']], $updated_entity->get('field_rest_test_multivalue')->getValue()); Chris@18: $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); Chris@18: $prior_revision_id = (int) $updated_entity->getRevisionId(); Chris@18: Chris@18: // Multi-value field: add one item before the existing one, and one after. Chris@18: $doc_add_items = $doc_multi_value_tests; Chris@18: $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => 'Three']; Chris@18: $request_options[RequestOptions::BODY] = Json::encode($doc_add_items); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response); Chris@18: $expected_document = [ Chris@18: 0 => ['value' => 'One'], Chris@18: 1 => ['value' => 'Two'], Chris@18: 2 => ['value' => 'Three'], Chris@18: ]; Chris@18: $updated_entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue()); Chris@18: $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); Chris@18: $prior_revision_id = (int) $updated_entity->getRevisionId(); Chris@18: Chris@18: // Finally, assert that when Content Moderation is installed, a new revision Chris@18: // is automatically created when PATCHing for entity types that have a Chris@18: // moderation handler. Chris@18: // @see \Drupal\content_moderation\Entity\Handler\ModerationHandler::onPresave() Chris@18: // @see \Drupal\content_moderation\EntityTypeInfo::$moderationHandlers Chris@18: if ($updated_entity instanceof EntityPublishedInterface) { Chris@18: $updated_entity->setPublished()->save(); Chris@18: } Chris@18: $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.'); Chris@18: Chris@18: if (!\Drupal::service('content_moderation.moderation_information')->canModerateEntitiesOfEntityType($this->entity->getEntityType())) { Chris@18: return; Chris@18: } Chris@18: Chris@18: $workflow = $this->createEditorialWorkflow(); Chris@18: $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle()); Chris@18: $workflow->save(); Chris@18: $this->grantPermissionsToTestedRole(['use editorial transition publish']); Chris@18: $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => '3']; Chris@18: $request_options[RequestOptions::BODY] = Json::encode($doc_add_items); Chris@18: $response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response); Chris@18: $expected_document = [ Chris@18: 0 => ['value' => 'One'], Chris@18: 1 => ['value' => 'Two'], Chris@18: 2 => ['value' => '3'], Chris@18: ]; Chris@18: $updated_entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue()); Chris@18: if ($this->entity->getEntityType()->hasHandlerClass('moderation')) { Chris@18: $this->assertLessThan((int) $updated_entity->getRevisionId(), $prior_revision_id); Chris@18: } Chris@18: else { Chris@18: $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); Chris@18: } Chris@18: Chris@18: // Ensure that PATCHing an entity that is not the latest revision is Chris@18: // unsupported. Chris@18: if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) { Chris@18: return; Chris@18: } Chris@18: assert($this->entity instanceof RevisionableInterface); Chris@18: Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: $request_options[RequestOptions::BODY] = Json::encode([ Chris@18: 'data' => [ Chris@18: 'type' => static::$resourceTypeName, Chris@18: 'id' => $this->entity->uuid(), Chris@18: ], Chris@18: ]); Chris@18: $this->setUpAuthorization('PATCH'); Chris@18: $this->grantPermissionsToTestedRole([ Chris@18: 'use editorial transition create_new_draft', Chris@18: 'use editorial transition archived_published', Chris@18: 'use editorial transition published', Chris@18: ]); Chris@18: Chris@18: // Disallow PATCHing an entity that has a pending revision. Chris@18: $updated_entity->set('moderation_state', 'draft'); Chris@18: $updated_entity->setNewRevision(); Chris@18: $updated_entity->save(); Chris@18: $actual_response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, 'Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/jsonapi/issues/2795279.', $url, $actual_response); Chris@18: Chris@18: // Allow PATCHing an unpublished default revision. Chris@18: $updated_entity->set('moderation_state', 'archived'); Chris@18: $updated_entity->setNewRevision(); Chris@18: $updated_entity->save(); Chris@18: $actual_response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertSame(200, $actual_response->getStatusCode()); Chris@18: Chris@18: // Allow PATCHing an unpublished default revision. (An entity that Chris@18: // transitions from archived to draft remains an unpublished default Chris@18: // revision.) Chris@18: $updated_entity->set('moderation_state', 'draft'); Chris@18: $updated_entity->setNewRevision(); Chris@18: $updated_entity->save(); Chris@18: $actual_response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertSame(200, $actual_response->getStatusCode()); Chris@18: Chris@18: // Allow PATCHing a published default revision. Chris@18: $updated_entity->set('moderation_state', 'published'); Chris@18: $updated_entity->setNewRevision(); Chris@18: $updated_entity->save(); Chris@18: $actual_response = $this->request('PATCH', $url, $request_options); Chris@18: $this->assertSame(200, $actual_response->getStatusCode()); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests DELETEing an individual resource, plus edge cases to ensure good DX. Chris@18: */ Chris@18: public function testDeleteIndividual() { Chris@18: // @todo Remove this in https://www.drupal.org/node/2300677. Chris@18: if ($this->entity instanceof ConfigEntityInterface) { Chris@18: $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.'); Chris@18: return; Chris@18: } Chris@18: Chris@18: // The URL and Guzzle request options that will be used in this test. The Chris@18: // request options will be modified/expanded throughout this test: Chris@18: // - to first test all mistakes a developer might make, and assert that the Chris@18: // error responses provide a good DX Chris@18: // - to eventually result in a well-formed request that succeeds. Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); Chris@18: /* $url = $this->entity->toUrl('jsonapi'); */ Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // DX: 405 when read-only mode is enabled. Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); Chris@18: $this->assertSame(['GET'], $response->getHeader('Allow')); Chris@18: Chris@18: $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); Chris@18: Chris@18: // DX: 403 when unauthorized. Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE'); Chris@18: $this->assertResourceErrorResponse(403, (string) $reason, $url, $response, FALSE); Chris@18: Chris@18: $this->setUpAuthorization('DELETE'); Chris@18: Chris@18: // 204 for well-formed request. Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertResourceResponse(204, NULL, $response); Chris@18: Chris@18: // DX: 404 when non-existent. Chris@18: $response = $this->request('DELETE', $url, $request_options); Chris@18: $this->assertSame(404, $response->getStatusCode()); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Recursively sorts an array by key. Chris@18: * Chris@18: * @param array $array Chris@18: * An array to sort. Chris@18: */ Chris@18: protected static function recursiveKsort(array &$array) { Chris@18: // First, sort the main array. Chris@18: ksort($array); Chris@18: Chris@18: // Then check for child arrays. Chris@18: foreach ($array as $key => &$value) { Chris@18: if (is_array($value)) { Chris@18: static::recursiveKsort($value); Chris@18: } Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Returns Guzzle request options for authentication. Chris@18: * Chris@18: * @return array Chris@18: * Guzzle request options to use for authentication. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function getAuthenticationRequestOptions() { Chris@18: return [ Chris@18: 'headers' => [ Chris@18: 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Clones the given entity and modifies all PATCH-protected fields. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity being tested and to modify. Chris@18: * Chris@18: * @return array Chris@18: * Contains two items: Chris@18: * 1. The modified entity object. Chris@18: * 2. The original field values, keyed by field name. Chris@18: * Chris@18: * @internal Chris@18: */ Chris@18: protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) { Chris@18: $modified_entity = clone $entity; Chris@18: $original_values = []; Chris@18: foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) { Chris@18: $field = $modified_entity->get($field_name); Chris@18: $original_values[$field_name] = $field->getValue(); Chris@18: switch ($field->getItemDefinition()->getClass()) { Chris@18: case BooleanItem::class: Chris@18: // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50% Chris@18: // chance of not picking a different value. Chris@18: $field->value = ((int) $field->value) === 1 ? '0' : '1'; Chris@18: break; Chris@18: Chris@18: case PathItem::class: Chris@18: // PathItem::generateSampleValue() doesn't set a PID, which causes Chris@18: // PathItem::postSave() to fail. Keep the PID (and other properties), Chris@18: // just modify the alias. Chris@18: $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3))); Chris@18: break; Chris@18: Chris@18: default: Chris@18: $original_field = clone $field; Chris@18: while ($field->equals($original_field)) { Chris@18: $field->generateSampleItems(); Chris@18: } Chris@18: break; Chris@18: } Chris@18: } Chris@18: Chris@18: return [$modified_entity, $original_values]; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the normalized POST entity with random values for its unique fields. Chris@18: * Chris@18: * @see ::testPostIndividual Chris@18: * @see ::getPostDocument Chris@18: * Chris@18: * @return array Chris@18: * An array structure as returned by ::getNormalizedPostEntity(). Chris@18: */ Chris@18: protected function getModifiedEntityForPostTesting() { Chris@18: $document = $this->getPostDocument(); Chris@18: Chris@18: // Ensure that all the unique fields of the entity type get a new random Chris@18: // value. Chris@18: foreach (static::$uniqueFieldNames as $field_name) { Chris@18: $field_definition = $this->entity->getFieldDefinition($field_name); Chris@18: $field_type_class = $field_definition->getItemDefinition()->getClass(); Chris@18: $document['data']['attributes'][$field_name] = $field_type_class::generateSampleValue($field_definition); Chris@18: } Chris@18: Chris@18: return $document; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests sparse field sets. Chris@18: * Chris@18: * @param \Drupal\Core\Url $url Chris@18: * The base URL with which to test includes. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function doTestSparseFieldSets(Url $url, array $request_options) { Chris@18: $field_sets = $this->getSparseFieldSets(); Chris@18: $expected_cacheability = new CacheableMetadata(); Chris@18: foreach ($field_sets as $type => $field_set) { Chris@18: if ($type === 'all') { Chris@18: assert($this->getExpectedCacheTags($field_set) === $this->getExpectedCacheTags()); Chris@18: assert($this->getExpectedCacheContexts($field_set) === $this->getExpectedCacheContexts()); Chris@18: } Chris@18: $query = ['fields[' . static::$resourceTypeName . ']' => implode(',', $field_set)]; Chris@18: $expected_document = $this->getExpectedDocument(); Chris@18: $expected_cacheability->setCacheTags($this->getExpectedCacheTags($field_set)); Chris@18: $expected_cacheability->setCacheContexts($this->getExpectedCacheContexts($field_set)); Chris@18: // This tests sparse field sets on included entities. Chris@18: if (strpos($type, 'nested') === 0) { Chris@18: $this->grantPermissionsToTestedRole(['access user profiles']); Chris@18: $query['fields[user--user]'] = implode(',', $field_set); Chris@18: $query['include'] = 'uid'; Chris@18: $owner = $this->entity->getOwner(); Chris@18: $owner_resource = static::toResourceIdentifier($owner); Chris@18: foreach ($field_set as $field_name) { Chris@18: $owner_resource['attributes'][$field_name] = $this->serializer->normalize($owner->get($field_name)[0]->get('value'), 'api_json'); Chris@18: } Chris@18: $owner_resource['links']['self']['href'] = static::getResourceLink($owner_resource); Chris@18: $expected_document['included'] = [$owner_resource]; Chris@18: $expected_cacheability->addCacheableDependency($owner); Chris@18: $expected_cacheability->addCacheableDependency(static::entityAccess($owner, 'view', $this->account)); Chris@18: } Chris@18: // Remove fields not in the sparse field set. Chris@18: foreach (['attributes', 'relationships'] as $member) { Chris@18: if (!empty($expected_document['data'][$member])) { Chris@18: $remaining = array_intersect_key( Chris@18: $expected_document['data'][$member], Chris@18: array_flip($field_set) Chris@18: ); Chris@18: if (empty($remaining)) { Chris@18: unset($expected_document['data'][$member]); Chris@18: } Chris@18: else { Chris@18: $expected_document['data'][$member] = $remaining; Chris@18: } Chris@18: } Chris@18: } Chris@18: $url->setOption('query', $query); Chris@18: // 'self' link should include the 'fields' query param. Chris@18: $expected_document['links']['self']['href'] = $url->setAbsolute()->toString(); Chris@18: Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: // Dynamic Page Cache MISS because cache should vary based on the 'field' Chris@18: // query param. (Or uncacheable if expensive cache context.) Chris@18: $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse( Chris@18: 200, Chris@18: $expected_document, Chris@18: $response, Chris@18: $expected_cacheability->getCacheTags(), Chris@18: $expected_cacheability->getCacheContexts(), Chris@18: FALSE, Chris@18: $dynamic_cache Chris@18: ); Chris@18: } Chris@18: // Test Dynamic Page Cache HIT for a query with the same field set (unless Chris@18: // expensive cache context is present). Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE'); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests included resources. Chris@18: * Chris@18: * @param \Drupal\Core\Url $url Chris@18: * The base URL with which to test includes. Chris@18: * @param array $request_options Chris@18: * Request options to apply. Chris@18: * Chris@18: * @see \GuzzleHttp\ClientInterface::request() Chris@18: */ Chris@18: protected function doTestIncluded(Url $url, array $request_options) { Chris@18: $relationship_field_names = $this->getRelationshipFieldNames($this->entity); Chris@18: // If there are no relationship fields, we can't include anything. Chris@18: if (empty($relationship_field_names)) { Chris@18: return; Chris@18: } Chris@18: Chris@18: $field_sets = [ Chris@18: 'empty' => [], Chris@18: 'all' => $relationship_field_names, Chris@18: ]; Chris@18: if (count($relationship_field_names) > 1) { Chris@18: $about_half_the_fields = floor(count($relationship_field_names) / 2); Chris@18: $field_sets['some'] = array_slice($relationship_field_names, $about_half_the_fields); Chris@18: Chris@18: $nested_includes = $this->getNestedIncludePaths(); Chris@18: if (!empty($nested_includes) && !in_array($nested_includes, $field_sets)) { Chris@18: $field_sets['nested'] = $nested_includes; Chris@18: } Chris@18: } Chris@18: Chris@18: foreach ($field_sets as $type => $included_paths) { Chris@18: $this->grantIncludedPermissions($included_paths); Chris@18: $query = ['include' => implode(',', $included_paths)]; Chris@18: $url->setOption('query', $query); Chris@18: $actual_response = $this->request('GET', $url, $request_options); Chris@18: $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: // Dynamic Page Cache miss because cache should vary based on the Chris@18: // 'include' query param. Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: // MISS or UNCACHEABLE depends on data. It must not be HIT. Chris@18: $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse( Chris@18: 200, Chris@18: $expected_document, Chris@18: $actual_response, Chris@18: $expected_cacheability->getCacheTags(), Chris@18: $expected_cacheability->getCacheContexts(), Chris@18: FALSE, Chris@18: $dynamic_cache Chris@18: ); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Tests individual and collection revisions. Chris@18: */ Chris@18: public function testRevisions() { Chris@18: if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) { Chris@18: return; Chris@18: } Chris@18: assert($this->entity instanceof RevisionableInterface); Chris@18: Chris@18: // JSON:API will only support node and media revisions until Drupal core has Chris@18: // a generic revision access API. Chris@18: if (!static::$resourceTypeIsVersionable) { Chris@18: $this->setUpRevisionAuthorization('GET'); Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute(); Chris@18: $url->setOption('query', ['resourceVersion' => 'id:' . $this->entity->getRevisionId()]); Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: $response = $this->request('GET', $url, $request_options); Chris@18: $detail = 'JSON:API does not yet support resource versioning for this resource type.'; Chris@18: $detail .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.'; Chris@18: $detail .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.'; Chris@18: $expected_cache_contexts = [ Chris@18: 'url.path', Chris@18: 'url.query_args:resourceVersion', Chris@18: 'url.site', Chris@18: ]; Chris@18: $this->assertResourceErrorResponse(501, $detail, $url, $response, FALSE, ['http_response'], $expected_cache_contexts); Chris@18: return; Chris@18: } Chris@18: Chris@18: // Add a field to modify in order to test revisions. Chris@18: FieldStorageConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_revisionable_number', Chris@18: 'type' => 'integer', Chris@18: ])->setCardinality(1)->save(); Chris@18: FieldConfig::create([ Chris@18: 'entity_type' => static::$entityTypeId, Chris@18: 'field_name' => 'field_revisionable_number', Chris@18: 'bundle' => $this->entity->bundle(), Chris@18: ])->setLabel('Revisionable text field')->setTranslatable(FALSE)->save(); Chris@18: Chris@18: // Reload entity so that it has the new field. Chris@18: $entity = $this->entityLoadUnchanged($this->entity->id()); Chris@18: Chris@18: // Set up test data. Chris@18: /* @var \Drupal\Core\Entity\FieldableEntityInterface $entity */ Chris@18: $entity->set('field_revisionable_number', 42); Chris@18: $entity->save(); Chris@18: $original_revision_id = (int) $entity->getRevisionId(); Chris@18: Chris@18: $entity->set('field_revisionable_number', 99); Chris@18: $entity->setNewRevision(); Chris@18: $entity->save(); Chris@18: $latest_revision_id = (int) $entity->getRevisionId(); Chris@18: Chris@18: // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. Chris@18: $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute(); Chris@18: /* $url = $this->entity->toUrl('jsonapi'); */ Chris@18: $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(); Chris@18: $relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute(); Chris@18: $related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute(); Chris@18: $original_revision_id_url = clone $url; Chris@18: $original_revision_id_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); Chris@18: $original_revision_id_relationship_url = clone $relationship_url; Chris@18: $original_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); Chris@18: $original_revision_id_related_url = clone $related_url; Chris@18: $original_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); Chris@18: $latest_revision_id_url = clone $url; Chris@18: $latest_revision_id_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); Chris@18: $latest_revision_id_relationship_url = clone $relationship_url; Chris@18: $latest_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); Chris@18: $latest_revision_id_related_url = clone $related_url; Chris@18: $latest_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); Chris@18: $rel_latest_version_url = clone $url; Chris@18: $rel_latest_version_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); Chris@18: $rel_latest_version_relationship_url = clone $relationship_url; Chris@18: $rel_latest_version_relationship_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); Chris@18: $rel_latest_version_related_url = clone $related_url; Chris@18: $rel_latest_version_related_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); Chris@18: $rel_latest_version_collection_url = clone $collection_url; Chris@18: $rel_latest_version_collection_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); Chris@18: $rel_working_copy_url = clone $url; Chris@18: $rel_working_copy_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); Chris@18: $rel_working_copy_relationship_url = clone $relationship_url; Chris@18: $rel_working_copy_relationship_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); Chris@18: $rel_working_copy_related_url = clone $related_url; Chris@18: $rel_working_copy_related_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); Chris@18: $rel_working_copy_collection_url = clone $collection_url; Chris@18: $rel_working_copy_collection_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); Chris@18: $rel_invalid_collection_url = clone $collection_url; Chris@18: $rel_invalid_collection_url->setOption('query', ['resourceVersion' => 'rel:invalid']); Chris@18: $revision_id_key = 'drupal_internal__' . $this->entity->getEntityType()->getKey('revision'); Chris@18: $published_key = $this->entity->getEntityType()->getKey('published'); Chris@18: $revision_translation_affected_key = $this->entity->getEntityType()->getKey('revision_translation_affected'); Chris@18: Chris@18: $amend_relationship_urls = function (array &$document, $revision_id) { Chris@18: if (!empty($document['data']['relationships'])) { Chris@18: foreach ($document['data']['relationships'] as &$relationship) { Chris@18: $pattern = '/resourceVersion=id%3A\d/'; Chris@18: $replacement = 'resourceVersion=' . urlencode("id:$revision_id"); Chris@18: $relationship['links']['self']['href'] = preg_replace($pattern, $replacement, $relationship['links']['self']['href']); Chris@18: $relationship['links']['related']['href'] = preg_replace($pattern, $replacement, $relationship['links']['related']['href']); Chris@18: } Chris@18: } Chris@18: }; Chris@18: Chris@18: $request_options = []; Chris@18: $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; Chris@18: $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); Chris@18: Chris@18: // Ensure 403 forbidden on typical GET. Chris@18: $actual_response = $this->request('GET', $url, $request_options); Chris@18: $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); Chris@18: $result = $entity->access('view', $this->account, TRUE); Chris@18: $detail = 'The current user is not allowed to GET the selected resource.'; Chris@18: if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) { Chris@18: $detail .= ' ' . $reason; Chris@18: } Chris@18: $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: Chris@18: // Ensure that targeting a revision does not bypass access. Chris@18: $actual_response = $this->request('GET', $original_revision_id_url, $request_options); Chris@18: $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); Chris@18: $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.'; Chris@18: if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) { Chris@18: $detail .= ' ' . $reason; Chris@18: } Chris@18: $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: Chris@18: $this->setUpRevisionAuthorization('GET'); Chris@18: Chris@18: // Ensure that the URL without a `resourceVersion` query parameter returns Chris@18: // the default revision. This is always the latest revision when Chris@18: // content_moderation is not installed. Chris@18: $actual_response = $this->request('GET', $url, $request_options); Chris@18: $expected_document = $this->getExpectedDocument(); Chris@18: // The resource object should always links to the specific revision it Chris@18: // represents. Chris@18: $expected_document['data']['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString(); Chris@18: $amend_relationship_urls($expected_document, $latest_revision_id); Chris@18: // Resource objects always link to their specific revision by revision ID. Chris@18: $expected_document['data']['attributes'][$revision_id_key] = $latest_revision_id; Chris@18: $expected_document['data']['attributes']['field_revisionable_number'] = 99; Chris@18: $expected_cache_tags = $this->getExpectedCacheTags(); Chris@18: $expected_cache_contexts = $this->getExpectedCacheContexts(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // Fetch the same revision using its revision ID. Chris@18: $actual_response = $this->request('GET', $latest_revision_id_url, $request_options); Chris@18: // The top-level document object's `self` link should always link to the Chris@18: // request URL. Chris@18: $expected_document['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // Ensure dynamic cache HIT on second request when using a version Chris@18: // negotiator. Chris@18: $actual_response = $this->request('GET', $latest_revision_id_url, $request_options); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'HIT'); Chris@18: // Fetch the same revision using the `latest-version` link relation type Chris@18: // negotiator. Without content_moderation, this is always the most recent Chris@18: // revision. Chris@18: $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); Chris@18: $expected_document['links']['self']['href'] = $rel_latest_version_url->setAbsolute()->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // Fetch the same revision using the `working-copy` link relation type Chris@18: // negotiator. Without content_moderation, this is always the most recent Chris@18: // revision. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); Chris@18: $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: Chris@18: // Fetch the prior revision. Chris@18: $actual_response = $this->request('GET', $original_revision_id_url, $request_options); Chris@18: $expected_document['data']['attributes'][$revision_id_key] = $original_revision_id; Chris@18: $expected_document['data']['attributes']['field_revisionable_number'] = 42; Chris@18: $expected_document['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString(); Chris@18: // The resource object should always links to the specific revision it Chris@18: // represents. Chris@18: $expected_document['data']['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString(); Chris@18: $amend_relationship_urls($expected_document, $original_revision_id); Chris@18: // When the resource object is not the latest version or the working copy, Chris@18: // a link should be provided that links to those versions. Therefore, the Chris@18: // presence or absence of these links communicates the state of the resource Chris@18: // object. Chris@18: $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString(); Chris@18: $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: Chris@18: // Install content_moderation module. Chris@18: $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.'); Chris@18: Chris@18: // Set up an editorial workflow. Chris@18: $workflow = $this->createEditorialWorkflow(); Chris@18: $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle()); Chris@18: $workflow->save(); Chris@18: Chris@18: // Ensure the test entity has content_moderation fields attached to it. Chris@18: /* @var \Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\Entity\TranslatableRevisionableInterface $entity */ Chris@18: $entity = $this->entityStorage->load($entity->id()); Chris@18: Chris@18: // Set the published moderation state on the test entity. Chris@18: $entity->set('moderation_state', 'published'); Chris@18: $entity->setNewRevision(); Chris@18: $entity->save(); Chris@18: $default_revision_id = (int) $entity->getRevisionId(); Chris@18: Chris@18: // Fetch the published revision by using the `rel` version negotiator and Chris@18: // the `latest-version` version argument. With content_moderation, this is Chris@18: // now the most recent revision where the moderation state was the 'default' Chris@18: // one. Chris@18: $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); Chris@18: $expected_document['data']['attributes'][$revision_id_key] = $default_revision_id; Chris@18: $expected_document['data']['attributes']['moderation_state'] = 'published'; Chris@18: $expected_document['data']['attributes'][$published_key] = TRUE; Chris@18: $expected_document['data']['attributes']['field_revisionable_number'] = 99; Chris@18: $expected_document['links']['self']['href'] = $rel_latest_version_url->toString(); Chris@18: $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected(); Chris@18: // The resource object now must link to the new revision. Chris@18: $default_revision_id_url = clone $url; Chris@18: $default_revision_id_url = $default_revision_id_url->setOption('query', ['resourceVersion' => "id:$default_revision_id"]); Chris@18: $expected_document['data']['links']['self']['href'] = $default_revision_id_url->setAbsolute()->toString(); Chris@18: $amend_relationship_urls($expected_document, $default_revision_id); Chris@18: // Since the requested version is the latest version and working copy, there Chris@18: // should be no links. Chris@18: unset($expected_document['data']['links']['latest-version']); Chris@18: unset($expected_document['data']['links']['working-copy']); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // Fetch the collection URL using the `latest-version` version argument. Chris@18: $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options); Chris@18: $expected_response = $this->getExpectedCollectionResponse([$entity], $rel_latest_version_collection_url->toString(), $request_options); Chris@18: $expected_collection_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: // Fetch the published revision by using the `working-copy` version Chris@18: // argument. With content_moderation, this is always the most recent Chris@18: // revision regardless of moderation state. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); Chris@18: $expected_document['links']['self']['href'] = $rel_working_copy_url->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // Fetch the collection URL using the `working-copy` version argument. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); Chris@18: $expected_collection_document['links']['self']['href'] = $rel_working_copy_collection_url->toString(); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: // @todo: remove the next assertion when Drupal core supports entity query access control on revisions. Chris@18: $rel_working_copy_collection_url_filtered = clone $rel_working_copy_collection_url; Chris@18: $rel_working_copy_collection_url_filtered->setOption('query', ['filter[foo]' => 'bar'] + $rel_working_copy_collection_url->getOption('query')); Chris@18: $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options); Chris@18: $filtered_collection_expected_cache_contexts = [ Chris@18: 'url.path', Chris@18: 'url.query_args:filter', Chris@18: 'url.query_args:resourceVersion', Chris@18: 'url.site', Chris@18: ]; Chris@18: $this->assertResourceErrorResponse(501, 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.', $rel_working_copy_collection_url_filtered, $actual_response, FALSE, ['http_response'], $filtered_collection_expected_cache_contexts); Chris@18: // Fetch the collection URL using an invalid version identifier. Chris@18: $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options); Chris@18: $invalid_version_expected_cache_contexts = [ Chris@18: 'url.path', Chris@18: 'url.query_args:resourceVersion', Chris@18: 'url.site', Chris@18: ]; Chris@18: $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, ['4xx-response', 'http_response'], $invalid_version_expected_cache_contexts); Chris@18: Chris@18: // Move the entity to its draft moderation state. Chris@18: $entity->set('field_revisionable_number', 42); Chris@18: // Change a relationship field so revisions can be tested on related and Chris@18: // relationship routes. Chris@18: $new_user = $this->createUser(); Chris@18: $new_user->save(); Chris@18: $entity->set('field_jsonapi_test_entity_ref', ['target_id' => $new_user->id()]); Chris@18: $entity->set('moderation_state', 'draft'); Chris@18: $entity->setNewRevision(); Chris@18: $entity->save(); Chris@18: $forward_revision_id = (int) $entity->getRevisionId(); Chris@18: Chris@18: // The `latest-version` link should *still* reference the same revision Chris@18: // since a draft is not a default revision. Chris@18: $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); Chris@18: $expected_document['links']['self']['href'] = $rel_latest_version_url->toString(); Chris@18: // Since the latest version is no longer also the working copy, a Chris@18: // `working-copy` link is required to indicate that there is a forward Chris@18: // revision available. Chris@18: $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // And the same should be true for collections. Chris@18: $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options); Chris@18: $expected_collection_document['data'][0] = $expected_document['data']; Chris@18: $expected_collection_document['links']['self']['href'] = $rel_latest_version_collection_url->toString(); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: // Ensure that the `latest-version` response is same as the default link, Chris@18: // aside from the document's `self` link. Chris@18: $actual_response = $this->request('GET', $url, $request_options); Chris@18: $expected_document['links']['self']['href'] = $url->toString(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // And the same should be true for collections. Chris@18: $actual_response = $this->request('GET', $collection_url, $request_options); Chris@18: $expected_collection_document['links']['self']['href'] = $collection_url->toString(); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: // Now, the `working-copy` link should reference the draft revision. This Chris@18: // is significant because without content_moderation, the two responses Chris@18: // would still been the same. Chris@18: // Chris@18: // Access is checked before any special permissions are granted. This Chris@18: // asserts a 403 forbidden if the user is not allowed to see unpublished Chris@18: // content. Chris@18: $result = $entity->access('view', $this->account, TRUE); Chris@18: if (!$result->isAllowed()) { Chris@18: $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); Chris@18: $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); Chris@18: $expected_cache_tags = Cache::mergeTags($expected_cacheability->getCacheTags(), $entity->getCacheTags()); Chris@18: $expected_cache_contexts = $expected_cacheability->getCacheContexts(); Chris@18: $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.'; Chris@18: $message = $result instanceof AccessResultReasonInterface ? trim($detail . ' ' . $result->getReason()) : $detail; Chris@18: $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // On the collection URL, we should expect to see the draft omitted from Chris@18: // the collection. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); Chris@18: $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options); Chris@18: $expected_collection_document = $expected_response->getResponseData(); Chris@18: $expected_collection_document['data'] = []; Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $access_denied_response = static::getAccessDeniedResponse($entity, $result, $url, NULL, $detail)->getResponseData(); Chris@18: static::addOmittedObject($expected_collection_document, static::errorsToOmittedObject($access_denied_response['errors'])); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: } Chris@18: Chris@18: // Since additional permissions are required to see 'draft' entities, Chris@18: // grant those permissions. Chris@18: $this->grantPermissionsToTestedRole($this->getEditorialPermissions()); Chris@18: Chris@18: // Now, the `working-copy` link should be latest revision and be accessible. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); Chris@18: $expected_document['data']['attributes'][$revision_id_key] = $forward_revision_id; Chris@18: $expected_document['data']['attributes']['moderation_state'] = 'draft'; Chris@18: $expected_document['data']['attributes'][$published_key] = FALSE; Chris@18: $expected_document['data']['attributes']['field_revisionable_number'] = 42; Chris@18: $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString(); Chris@18: $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected(); Chris@18: // The resource object now must link to the forward revision. Chris@18: $forward_revision_id_url = clone $url; Chris@18: $forward_revision_id_url = $forward_revision_id_url->setOption('query', ['resourceVersion' => "id:$forward_revision_id"]); Chris@18: $expected_document['data']['links']['self']['href'] = $forward_revision_id_url->setAbsolute()->toString(); Chris@18: $amend_relationship_urls($expected_document, $forward_revision_id); Chris@18: // Since the the working copy is not the default revision. A Chris@18: // `latest-version` link is required to indicate that the requested version Chris@18: // is not the default revision. Chris@18: unset($expected_document['data']['links']['working-copy']); Chris@18: $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString(); Chris@18: $expected_cache_tags = $this->getExpectedCacheTags(); Chris@18: $expected_cache_contexts = $this->getExpectedCacheContexts(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); Chris@18: // And the collection response should also have the latest revision. Chris@18: $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); Chris@18: $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options); Chris@18: $expected_collection_document = $expected_response->getResponseData(); Chris@18: $expected_collection_document['data'] = [$expected_document['data']]; Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: Chris@18: // Test relationship responses. Chris@18: // Fetch the prior revision's relationship URL. Chris@18: $test_relationship_urls = [ Chris@18: [ Chris@18: NULL, Chris@18: $relationship_url, Chris@18: $related_url, Chris@18: ], Chris@18: [ Chris@18: $original_revision_id, Chris@18: $original_revision_id_relationship_url, Chris@18: $original_revision_id_related_url, Chris@18: ], Chris@18: [ Chris@18: $latest_revision_id, Chris@18: $latest_revision_id_relationship_url, Chris@18: $latest_revision_id_related_url, Chris@18: ], Chris@18: [ Chris@18: $default_revision_id, Chris@18: $rel_latest_version_relationship_url, Chris@18: $rel_latest_version_related_url, Chris@18: ], Chris@18: [ Chris@18: $forward_revision_id, Chris@18: $rel_working_copy_relationship_url, Chris@18: $rel_working_copy_related_url, Chris@18: ], Chris@18: ]; Chris@18: foreach ($test_relationship_urls as $revision_case) { Chris@18: list($revision_id, $relationship_url, $related_url) = $revision_case; Chris@18: // Load the revision that will be requested. Chris@18: $this->entityStorage->resetCache([$entity->id()]); Chris@18: $revision = is_null($revision_id) Chris@18: ? $this->entityStorage->load($entity->id()) Chris@18: : $this->entityStorage->loadRevision($revision_id); Chris@18: // Request the relationship resource without access to the relationship Chris@18: // field. Chris@18: $actual_response = $this->request('GET', $relationship_url, $request_options); Chris@18: $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document['errors'][0]['links']['via']['href'] = $relationship_url->toString(); Chris@18: $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts()); Chris@18: // Request the related route. Chris@18: $actual_response = $this->request('GET', $related_url, $request_options); Chris@18: // @todo: refactor self::getExpectedRelatedResponses() into a function which returns a single response. Chris@18: $expected_response = $this->getExpectedRelatedResponses(['field_jsonapi_test_entity_ref'], $request_options, $revision)['field_jsonapi_test_entity_ref']; Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document['errors'][0]['links']['via']['href'] = $related_url->toString(); Chris@18: $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts()); Chris@18: } Chris@18: $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']); Chris@18: foreach ($test_relationship_urls as $revision_case) { Chris@18: list($revision_id, $relationship_url, $related_url) = $revision_case; Chris@18: // Load the revision that will be requested. Chris@18: $this->entityStorage->resetCache([$entity->id()]); Chris@18: $revision = is_null($revision_id) Chris@18: ? $this->entityStorage->load($entity->id()) Chris@18: : $this->entityStorage->loadRevision($revision_id); Chris@18: // Request the relationship resource after granting access to the Chris@18: // relationship field. Chris@18: $actual_response = $this->request('GET', $relationship_url, $request_options); Chris@18: $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); Chris@18: // Request the related route. Chris@18: $actual_response = $this->request('GET', $related_url, $request_options); Chris@18: $expected_response = $this->getExpectedRelatedResponse('field_jsonapi_test_entity_ref', $request_options, $revision); Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: $expected_document['links']['self']['href'] = $related_url->toString(); Chris@18: // MISS or UNCACHEABLE depends on data. It must not be HIT. Chris@18: $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; Chris@18: $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); Chris@18: } Chris@18: Chris@18: $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); Chris@18: Chris@18: // Ensures that PATCH and DELETE on individual resources with a Chris@18: // `resourceVersion` query parameter is not supported. Chris@18: $individual_urls = [ Chris@18: $original_revision_id_url, Chris@18: $latest_revision_id_url, Chris@18: $rel_latest_version_url, Chris@18: $rel_working_copy_url, Chris@18: ]; Chris@18: $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; Chris@18: foreach ($individual_urls as $url) { Chris@18: foreach (['PATCH', 'DELETE'] as $method) { Chris@18: $actual_response = $this->request($method, $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); Chris@18: } Chris@18: } Chris@18: Chris@18: // Ensures that PATCH, POST and DELETE on relationship resources with a Chris@18: // `resourceVersion` query parameter is not supported. Chris@18: $relationship_urls = [ Chris@18: $original_revision_id_relationship_url, Chris@18: $latest_revision_id_relationship_url, Chris@18: $rel_latest_version_relationship_url, Chris@18: $rel_working_copy_relationship_url, Chris@18: ]; Chris@18: foreach ($relationship_urls as $url) { Chris@18: foreach (['PATCH', 'POST', 'DELETE'] as $method) { Chris@18: $actual_response = $this->request($method, $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); Chris@18: } Chris@18: } Chris@18: Chris@18: // Ensures that POST on collection resources with a `resourceVersion` query Chris@18: // parameter is not supported. Chris@18: $collection_urls = [ Chris@18: $rel_latest_version_collection_url, Chris@18: $rel_working_copy_collection_url, Chris@18: ]; Chris@18: foreach ($collection_urls as $url) { Chris@18: foreach (['POST'] as $method) { Chris@18: $actual_response = $this->request($method, $url, $request_options); Chris@18: $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); Chris@18: } Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Decorates the expected response with included data and cache metadata. Chris@18: * Chris@18: * This adds the expected includes to the expected document and also builds Chris@18: * the expected cacheability for those includes. It does so based of responses Chris@18: * from the related routes for individual relationships. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceResponse $expected_response Chris@18: * The expected ResourceResponse. Chris@18: * @param \Drupal\jsonapi\ResourceResponse[] $related_responses Chris@18: * The related ResourceResponses, keyed by relationship field names. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The decorated ResourceResponse. Chris@18: */ Chris@18: protected static function decorateExpectedResponseForIncludedFields(ResourceResponse $expected_response, array $related_responses) { Chris@18: $expected_document = $expected_response->getResponseData(); Chris@18: $expected_cacheability = $expected_response->getCacheableMetadata(); Chris@18: foreach ($related_responses as $related_response) { Chris@18: $related_document = $related_response->getResponseData(); Chris@18: $expected_cacheability->addCacheableDependency($related_response->getCacheableMetadata()); Chris@18: $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); Chris@18: // If any of the related response documents had omitted items or errors, Chris@18: // we should later expect the document to have omitted items as well. Chris@18: if (!empty($related_document['errors'])) { Chris@18: static::addOmittedObject($expected_document, static::errorsToOmittedObject($related_document['errors'])); Chris@18: } Chris@18: if (!empty($related_document['meta']['omitted'])) { Chris@18: static::addOmittedObject($expected_document, $related_document['meta']['omitted']); Chris@18: } Chris@18: if (isset($related_document['data'])) { Chris@18: $related_data = $related_document['data']; Chris@18: $related_resources = (static::isResourceIdentifier($related_data)) Chris@18: ? [$related_data] Chris@18: : $related_data; Chris@18: foreach ($related_resources as $related_resource) { Chris@18: if (empty($expected_document['included']) || !static::collectionHasResourceIdentifier($related_resource, $expected_document['included'])) { Chris@18: $expected_document['included'][] = $related_resource; Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: return (new ResourceResponse($expected_document))->addCacheableDependency($expected_cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the expected individual ResourceResponse for GET. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The expected individual ResourceResponse. Chris@18: */ Chris@18: protected function getExpectedGetIndividualResourceResponse($status_code = 200) { Chris@18: $resource_response = new ResourceResponse($this->getExpectedDocument(), $status_code); Chris@18: $cacheability = new CacheableMetadata(); Chris@18: $cacheability->setCacheContexts($this->getExpectedCacheContexts()); Chris@18: $cacheability->setCacheTags($this->getExpectedCacheTags()); Chris@18: return $resource_response->addCacheableDependency($cacheability); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Returns an array of sparse fields sets to test. Chris@18: * Chris@18: * @return array Chris@18: * An array of sparse field sets (an array of field names), keyed by a label Chris@18: * for the field set. Chris@18: */ Chris@18: protected function getSparseFieldSets() { Chris@18: $field_names = array_keys($this->entity->toArray()); Chris@18: $field_sets = [ Chris@18: 'empty' => [], Chris@18: 'some' => array_slice($field_names, floor(count($field_names) / 2)), Chris@18: 'all' => $field_names, Chris@18: ]; Chris@18: if ($this->entity instanceof EntityOwnerInterface) { Chris@18: $field_sets['nested_empty_fieldset'] = $field_sets['empty']; Chris@18: $field_sets['nested_fieldset_with_owner_fieldset'] = ['name', 'created']; Chris@18: } Chris@18: return $field_sets; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets a list of public relationship names for the resource type under test. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface|null $entity Chris@18: * (optional) The entity for which to get relationship field names. Chris@18: * Chris@18: * @return array Chris@18: * An array of relationship field names. Chris@18: */ Chris@18: protected function getRelationshipFieldNames(EntityInterface $entity = NULL) { Chris@18: $entity = $entity ?: $this->entity; Chris@18: // Only content entity types can have relationships. Chris@18: $fields = $entity instanceof ContentEntityInterface Chris@18: ? iterator_to_array($entity) Chris@18: : []; Chris@18: return array_reduce($fields, function ($field_names, $field) { Chris@18: /* @var \Drupal\Core\Field\FieldItemListInterface $field */ Chris@18: if (static::isReferenceFieldDefinition($field->getFieldDefinition())) { Chris@18: $field_names[] = $this->resourceType->getPublicName($field->getName()); Chris@18: } Chris@18: return $field_names; Chris@18: }, []); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Authorize the user under test with additional permissions to view includes. Chris@18: * Chris@18: * @return array Chris@18: * An array of special permissions to be granted for certain relationship Chris@18: * paths where the keys are relationships paths and values are an array of Chris@18: * permissions. Chris@18: */ Chris@18: protected static function getIncludePermissions() { Chris@18: return []; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an array of permissions required to view and update any tested entity. Chris@18: * Chris@18: * @return string[] Chris@18: * An array of permission names. Chris@18: */ Chris@18: protected function getEditorialPermissions() { Chris@18: return ['view latest version', "view any unpublished content"]; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks access for the given operation on the given entity. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity for which to check field access. Chris@18: * @param string $operation Chris@18: * The operation for which to check access. Chris@18: * @param \Drupal\Core\Session\AccountInterface $account Chris@18: * The account for which to check access. Chris@18: * Chris@18: * @return \Drupal\Core\Access\AccessResultInterface Chris@18: * The AccessResult. Chris@18: */ Chris@18: protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) { Chris@18: // The default entity access control handler assumes that permissions do not Chris@18: // change during the lifetime of a request and caches access results. Chris@18: // However, we're changing permissions during a test run and need fresh Chris@18: // results, so reset the cache. Chris@18: \Drupal::entityTypeManager()->getAccessControlHandler($entity->getEntityTypeId())->resetCache(); Chris@18: return $entity->access($operation, $account, TRUE); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks access for the given field operation on the given entity. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity for which to check field access. Chris@18: * @param string $field_name Chris@18: * The field for which to check access. Chris@18: * @param string $operation Chris@18: * The operation for which to check access. Chris@18: * @param \Drupal\Core\Session\AccountInterface $account Chris@18: * The account for which to check access. Chris@18: * Chris@18: * @return \Drupal\Core\Access\AccessResultInterface Chris@18: * The AccessResult. Chris@18: */ Chris@18: protected static function entityFieldAccess(EntityInterface $entity, $field_name, $operation, AccountInterface $account) { Chris@18: $entity_access = static::entityAccess($entity, $operation === 'edit' ? 'update' : 'view', $account); Chris@18: $field_access = $entity->{$field_name}->access($operation, $account, TRUE); Chris@18: return $entity_access->andIf($field_access); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets an array of of all nested include paths to be tested. Chris@18: * Chris@18: * @param int $depth Chris@18: * (optional) The maximum depth to which included paths should be nested. Chris@18: * Chris@18: * @return array Chris@18: * An array of nested include paths. Chris@18: */ Chris@18: protected function getNestedIncludePaths($depth = 3) { Chris@18: $get_nested_relationship_field_names = function (EntityInterface $entity, $depth, $path = "") use (&$get_nested_relationship_field_names) { Chris@18: $relationship_field_names = $this->getRelationshipFieldNames($entity); Chris@18: if ($depth > 0) { Chris@18: $paths = []; Chris@18: foreach ($relationship_field_names as $field_name) { Chris@18: $next = ($path) ? "$path.$field_name" : $field_name; Chris@18: $internal_field_name = $this->resourceType->getInternalName($field_name); Chris@18: if ($target_entity = $entity->{$internal_field_name}->entity) { Chris@18: $deep = $get_nested_relationship_field_names($target_entity, $depth - 1, $next); Chris@18: $paths = array_merge($paths, $deep); Chris@18: } Chris@18: else { Chris@18: $paths[] = $next; Chris@18: } Chris@18: } Chris@18: return $paths; Chris@18: } Chris@18: return array_map(function ($target_name) use ($path) { Chris@18: return "$path.$target_name"; Chris@18: }, $relationship_field_names); Chris@18: }; Chris@18: return $get_nested_relationship_field_names($this->entity, $depth); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Determines if a given field definition is a reference field. Chris@18: * Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition to inspect. Chris@18: * Chris@18: * @return bool Chris@18: * TRUE if the field definition is found to be a reference field. FALSE Chris@18: * otherwise. Chris@18: */ Chris@18: protected static function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) { Chris@18: /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */ Chris@18: $item_definition = $field_definition->getItemDefinition(); Chris@18: $main_property = $item_definition->getMainPropertyName(); Chris@18: $property_definition = $item_definition->getPropertyDefinition($main_property); Chris@18: return $property_definition instanceof DataReferenceTargetDefinition; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Grants authorization to view includes. Chris@18: * Chris@18: * @param string[] $include_paths Chris@18: * An array of include paths for which to grant access. Chris@18: */ Chris@18: protected function grantIncludedPermissions(array $include_paths = []) { Chris@18: $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($include_paths)); Chris@18: $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', [])); Chris@18: // Always grant access to 'view' the test entity reference field. Chris@18: $flattened_permissions[] = 'field_jsonapi_test_entity_ref view access'; Chris@18: $this->grantPermissionsToTestedRole($flattened_permissions); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Loads an entity in the test container, ignoring the static cache. Chris@18: * Chris@18: * @param int $id Chris@18: * The entity ID. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface|null Chris@18: * The loaded entity. Chris@18: * Chris@18: * @todo Remove this after https://www.drupal.org/project/drupal/issues/3038706 lands. Chris@18: */ Chris@18: protected function entityLoadUnchanged($id) { Chris@18: $this->entityStorage->resetCache(); Chris@18: return $this->entityStorage->loadUnchanged($id); Chris@18: } Chris@18: Chris@18: }