jsonapi.api.php | 6 + src/EntityToJsonApi.php | 3 +- src/Normalizer/HttpExceptionNormalizer.php | 4 +- src/ResourceType/ResourceTypeRepository.php | 20 +- tests/src/Functional/NodeTest.php | 274 ++++++ tests/src/Functional/ResourceTestBase.php | 1212 +++++++++++++++++++++++++++ tests/src/Functional/TermTest.php | 372 ++++++++ 7 files changed, 1878 insertions(+), 13 deletions(-) diff --git a/jsonapi.api.php b/jsonapi.api.php index 67d5dfb..15aa396 100644 --- a/jsonapi.api.php +++ b/jsonapi.api.php @@ -85,6 +85,7 @@ * - Custom field normalization is not supported; only normalizers at the * "DataType" plugin level are supported (these are a level below field * types). + * - All available authentication mechanisms are allowed. * * The JSON API module does provide a PHP API to generate a JSON API * representation of entities: @@ -122,5 +123,10 @@ * * @see http://jsonapi.org/faq/#what-is-the-meaning-of-json-apis-version * + * Tests: subclasses of base test classes may contain BC breaks between minor + * releases, to allow minor releases to A) comply better with the JSON API spec, + * B) guarantee that all resource types (and therefore entity types) function as + * expected, C) update to future versions of the JSON API spec. + * * @} */ diff --git a/src/EntityToJsonApi.php b/src/EntityToJsonApi.php index 7a6bd16..09f97be 100644 --- a/src/EntityToJsonApi.php +++ b/src/EntityToJsonApi.php @@ -98,7 +98,8 @@ class EntityToJsonApi { */ protected function calculateContext(EntityInterface $entity) { // TODO: Supporting includes requires adding the 'include' query string. - $request = new Request(); + $path = sprintf('/jsonapi/%s/%s/%s', $entity->getEntityTypeId(), $entity->bundle(), $entity->uuid()); + $request = Request::create($path, 'GET'); return [ 'account' => $this->currentUser, 'cacheable_metadata' => new CacheableMetadata(), diff --git a/src/Normalizer/HttpExceptionNormalizer.php b/src/Normalizer/HttpExceptionNormalizer.php index f56040f..409fff0 100644 --- a/src/Normalizer/HttpExceptionNormalizer.php +++ b/src/Normalizer/HttpExceptionNormalizer.php @@ -102,8 +102,10 @@ class HttpExceptionNormalizer extends NormalizerBase { * * @return string * URL pointing to the specific RFC-2616 section. + * + * @internal */ - protected function getInfoUrl($status_code) { + public static function getInfoUrl($status_code) { // Depending on the error code we'll return a different URL. $url = 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html'; $sections = [ diff --git a/src/ResourceType/ResourceTypeRepository.php b/src/ResourceType/ResourceTypeRepository.php index eb8a763..1bf350f 100644 --- a/src/ResourceType/ResourceTypeRepository.php +++ b/src/ResourceType/ResourceTypeRepository.php @@ -62,17 +62,15 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface { * {@inheritdoc} */ public function all() { - if (!$this->all) { - $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions()); - foreach ($entity_type_ids as $entity_type_id) { - $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) { - return new ResourceType( - $entity_type_id, - $bundle, - $this->entityTypeManager->getDefinition($entity_type_id)->getClass() - ); - }, array_keys($this->bundleManager->getBundleInfo($entity_type_id)))); - } + $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions()); + foreach ($entity_type_ids as $entity_type_id) { + $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) { + return new ResourceType( + $entity_type_id, + $bundle, + $this->entityTypeManager->getDefinition($entity_type_id)->getClass() + ); + }, array_keys($this->bundleManager->getBundleInfo($entity_type_id)))); } return $this->all; } diff --git a/tests/src/Functional/NodeTest.php b/tests/src/Functional/NodeTest.php new file mode 100644 index 0000000..fbd8ac5 --- /dev/null +++ b/tests/src/Functional/NodeTest.php @@ -0,0 +1,274 @@ + NULL, + 'created' => "The 'administer nodes' permission is required.", + 'changed' => NULL, + 'promote' => "The 'administer nodes' permission is required.", + 'sticky' => "The 'administer nodes' permission is required.", + 'path' => "The following permissions are required: 'create url aliases' OR 'administer url aliases'.", + ]; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['access content', 'create camelids content']); + break; + case 'PATCH': + // Do not grant the 'create url aliases' permission to test the case + // when the path field is protected/not accessible, see + // \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase + // for a positive test. + $this->grantPermissionsToTestedRole(['access content', 'edit any camelids content']); + break; + case 'DELETE': + $this->grantPermissionsToTestedRole(['access content', 'delete any camelids content']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + if (!NodeType::load('camelids')) { + // Create a "Camelids" node type. + NodeType::create([ + 'name' => 'Camelids', + 'type' => 'camelids', + ])->save(); + } + + // Create a "Llama" node. + $node = Node::create(['type' => 'camelids']); + $node->setTitle('Llama') + ->setOwnerId($this->account->id()) + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789) + ->setRevisionCreationTime(123456789) + ->set('path', '/llama') + ->save(); + + return $node; + } + + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $author = User::load($this->entity->getOwnerId()); + $self_url = Url::fromUri('base:/jsonapi/node/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + return [ + 'jsonapi' => [ + 'meta' => [ + 'links' => [ + 'self' => 'http://jsonapi.org/format/1.0/', + ], + ], + 'version' => '1.0', + ], + 'links' => [ + 'self' => $self_url, + ], + 'data' => [ + 'id' => $this->entity->uuid(), + 'type' => 'node--camelids', + 'links' => [ + 'self' => $self_url, + ], + 'attributes' => [ + 'created' => 123456789, + // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932 +// 'created' => $this->formatExpectedTimestampItemValues(123456789), + 'changed' => $this->entity->getChangedTime(), + // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932 +// 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), + 'default_langcode' => TRUE, + 'langcode' => 'en', + 'nid' => 1, + 'path' => [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + ], + 'promote' => TRUE, + 'revision_default' => TRUE, + 'revision_log' => NULL, + 'revision_timestamp' => 123456789, + // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932 +// 'revision_timestamp' => $this->formatExpectedTimestampItemValues(123456789), + // @todo Attempt to remove this in https://www.drupal.org/project/drupal/issues/2933518. + 'revision_translation_affected' => TRUE, + 'status' => TRUE, + 'sticky' => FALSE, + 'title' => 'Llama', + 'uuid' => $this->entity->uuid(), + 'vid' => 1, + ], + 'relationships' => [ + 'type' => [ + 'data' => [ + 'id' => NodeType::load('camelids')->uuid(), + 'type' => 'node_type--node_type', + ], + 'links' => [ + 'related' => $self_url . '/type', + 'self' => $self_url . '/relationships/type', + ], + ], + 'uid' => [ + 'data' => [ + 'id' => $author->uuid(), + 'type' => 'user--user', + ], + 'links' => [ + 'related' => $self_url . '/uid', + 'self' => $self_url . '/relationships/uid', + ], + ], + 'revision_uid' => [ + 'data' => [ + 'id' => $author->uuid(), + 'type' => 'user--user' + ], + 'links' => [ + 'related' => $self_url . '/revision_uid', + 'self' => $self_url . '/relationships/revision_uid', + ] + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'data' => [ + 'id' => $this->entity->uuid(), + 'type' => 'node--camelids', + 'attributes' => [ + 'title' => 'Dramallama', + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + return "The 'access content' permission is required."; + } + + /** + * Tests PATCHing a node's path with and without 'create url aliases'. + * + * For a positive test, see the similar test coverage for Term. + * + * @see \Drupal\Tests\jsonapi\Functional\TermTest::testPatchPath() + * @see \Drupal\Tests\rest\Functional\EntityResource\Term\TermResourceTestBase::testPatchPath() + */ + public function testPatchPath() { + $this->setUpAuthorization('GET'); + $this->setUpAuthorization('PATCH'); + + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + + // GET node's current normalization. + $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET')); + $normalization = Json::decode((string) $response->getBody()); + + // Change node's path alias. + $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world'; + + // Create node PATCH request. + $request_options = $this->getAuthenticationRequestOptions('PATCH'); + $request_options[RequestOptions::BODY] = Json::encode($normalization); + + // PATCH request: 403 when creating URL aliases unauthorized. + $response = $this->request('PATCH', $url, $request_options); + $expected = [ + 'errors' => [ + [ + 'title' => 'Forbidden', + 'status' => 403, + 'detail' => "The current user is not allowed to PATCH the selected field (path). The following permissions are required: 'create url aliases' OR 'administer url aliases'.", + 'links' => [ + 'info' => HttpExceptionNormalizer::getInfoUrl(403), + ], + 'code' => 0, + 'id' => '/node--camelids/' . $this->entity->uuid(), + 'source' => [ + 'pointer' => '/data/attributes/path', + ], + ], + ], + ]; + $this->assertResourceResponse(403, Json::encode($expected), $response); + + // Grant permission to create URL aliases. + $this->grantPermissionsToTestedRole(['create url aliases']); + + // Repeat PATCH request: 200. + $response = $this->request('PATCH', $url, $request_options); + // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939 + $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'node:1'], ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site', 'user.permissions']); + $updated_normalization = Json::decode((string) $response->getBody()); + $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']); + } + +} diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php new file mode 100644 index 0000000..78f5116 --- /dev/null +++ b/tests/src/Functional/ResourceTestBase.php @@ -0,0 +1,1212 @@ +serializer = $this->container->get('jsonapi.serializer_do_not_use_removal_imminent'); + $this->entityToJsonApi = $this->container->get('jsonapi.entity.to_jsonapi'); + + // Ensure the anonymous user role has no permissions at all. + $user_role = Role::load(RoleInterface::ANONYMOUS_ID); + foreach ($user_role->getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.'); + + // Ensure the authenticated user role has no permissions at all. + $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); + foreach ($user_role->getPermissions() as $permission) { + $user_role->revokePermission($permission); + } + $user_role->save(); + assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.'); + + // Create an account. + $this->account = $this->createUser(); + + // Create an entity. + $this->entityStorage = $this->container->get('entity_type.manager') + ->getStorage(static::$entityTypeId); + $this->entity = $this->createEntity(); + \Drupal::service('router.builder')->rebuild(); + + /* + if ($this->entity instanceof FieldableEntityInterface) { + // Add access-protected field. + FieldStorageConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test', + 'type' => 'text', + ]) + ->setCardinality(1) + ->save(); + FieldConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test', + 'bundle' => $this->entity->bundle(), + ]) + ->setLabel('Test field') + ->setTranslatable(FALSE) + ->save(); + + // Add multi-value field. + FieldStorageConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test_multivalue', + 'type' => 'string', + ]) + ->setCardinality(3) + ->save(); + FieldConfig::create([ + 'entity_type' => static::$entityTypeId, + 'field_name' => 'field_rest_test_multivalue', + 'bundle' => $this->entity->bundle(), + ]) + ->setLabel('Test field: multi-value') + ->setTranslatable(FALSE) + ->save(); + + // Reload entity so that it has the new field. + $reloaded_entity = $this->entityStorage->loadUnchanged($this->entity->id()); + // Some entity types are not stored, hence they cannot be reloaded. + if ($reloaded_entity !== NULL) { + $this->entity = $reloaded_entity; + + // Set a default value on the fields. + $this->entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']); + $this->entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]); + $this->entity->save(); + } + } + */ + } + + /** + * Creates the entity to be tested. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity to be tested. + */ + abstract protected function createEntity(); + + /** + * Returns the expected normalization of the entity. + * + * @see ::createEntity() + * + * @return array + */ + abstract protected function getExpectedNormalizedEntity(); + + /** + * Returns the normalized POST entity. + * + * @see ::testPost + * + * @return array + */ + abstract protected function getNormalizedPostEntity(); + + /** + * Returns the normalized PATCH entity. + * + * By default, reuses ::getNormalizedPostEntity(), which works fine for most + * entity types. A counterexample: the 'comment' entity type. + * + * @see ::testPatch + * + * @return array + */ + protected function getNormalizedPatchEntity() { + return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getNormalizedPostEntity()); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessCacheability() { + return (new CacheableMetadata()) + ->setCacheTags(['4xx-response', 'http_response']) + ->setCacheContexts(['user.permissions']); + } + + /** + * The expected cache tags for the GET/HEAD response of the test entity. + * + * @see ::testGet + * + * @return string[] + */ + protected function getExpectedCacheTags() { + $expected_cache_tags = [ + 'http_response', + ]; + return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags()); + } + + /** + * The expected cache contexts for the GET/HEAD response of the test entity. + * + * @see ::testGet + * + * @return string[] + */ + protected function getExpectedCacheContexts() { + return [ + // Cache contexts for JSON API URL query parameters. + 'url.query_args:fields', + 'url.query_args:filter', + 'url.query_args:include', + 'url.query_args:page', + 'url.query_args:sort', + // Drupal defaults. + 'url.site', + 'user.permissions', + ]; + } + + /** + * Sets up the necessary authorization. + * + * In case of a test verifying publicly accessible REST resources: grant + * permissions to the anonymous user role. + * + * In case of a test verifying behavior when using a particular authentication + * provider: create a user with a particular set of permissions. + * + * Because of the $method parameter, it's possible to first set up + * authentication for only GET, then add POST, et cetera. This then also + * allows for verifying a 403 in case of missing authorization. + * + * @param string $method + * The HTTP method for which to set up authentication. + * + * @see ::grantPermissionsToAnonymousRole() + * @see ::grantPermissionsToAuthenticatedRole() + */ + abstract protected function setUpAuthorization($method); + + /** + * Return the expected error message. + * + * @param string $method + * The HTTP method (GET, POST, PATCH, DELETE). + * + * @return string + * The error string. + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + return sprintf('The current user is not allowed to %s the selected resource.', strtoupper($method)); + } + + /** + * Grants permissions to the authenticated role. + * + * @param string[] $permissions + * Permissions to grant. + */ + protected function grantPermissionsToTestedRole(array $permissions) { + $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions); + } + + /** + * Performs a HTTP request. Wraps the Guzzle HTTP client. + * + * Why wrap the Guzzle HTTP client? Because we want to keep the actual test + * code as simple as possible, and hence not require them to specify the + * 'http_errors = FALSE' request option, nor do we want them to have to + * convert Drupal Url objects to strings. + * + * We also don't want to follow redirects automatically, to ensure these tests + * are able to detect when redirects are added or removed. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function request($method, Url $url, array $request_options) { + $request_options[RequestOptions::HTTP_ERRORS] = FALSE; + $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE; + $request_options = $this->decorateWithXdebugCookie($request_options); + $client = $this->getSession()->getDriver()->getClient()->getClient(); + return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options); + } + + /** + * Asserts that a resource response has the given status code and body. + * + * @param int $expected_status_code + * The expected response status. + * @param string|false $expected_body + * The expected response body. FALSE in case this should not be asserted. + * @param \Psr\Http\Message\ResponseInterface $response + * The response to assert. + * @param string[]|false $expected_cache_tags + * (optional) The expected cache tags in the X-Drupal-Cache-Tags response + * header, or FALSE if that header should be absent. Defaults to FALSE. + * @param string[]|false $expected_cache_contexts + * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts + * response header, or FALSE if that header should be absent. Defaults to + * FALSE. + * @param string|false $expected_page_cache_header_value + * (optional) The expected X-Drupal-Cache response header value, or FALSE if + * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults + * to FALSE. + * @param string|false $expected_dynamic_page_cache_header_value + * (optional) The expected X-Drupal-Dynamic-Cache response header value, or + * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. + * Defaults to FALSE. + */ + protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { + $this->assertSame($expected_status_code, $response->getStatusCode()); + if ($expected_status_code === 204) { + // DELETE responses should not include a Content-Type header. But Apache + // sets it to 'text/html' by default. We also cannot detect the presence + // of Apache either here in the CLI. For now having this documented here + // is all we can do. + // $this->assertSame(FALSE, $response->hasHeader('Content-Type')); + $this->assertSame('', (string) $response->getBody()); + } + else { + $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type')); + if ($expected_body !== FALSE) { + $this->assertSame($expected_body, (string) $response->getBody()); + } + } + + // Expected cache tags: X-Drupal-Cache-Tags header. + $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags')); + if (is_array($expected_cache_tags)) { + $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); + } + + // Expected cache contexts: X-Drupal-Cache-Contexts header. + $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts')); + if (is_array($expected_cache_contexts)) { + $this->assertSame($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); + } + + // Expected Page Cache header value: X-Drupal-Cache header. + if ($expected_page_cache_header_value !== FALSE) { + $this->assertTrue($response->hasHeader('X-Drupal-Cache')); + $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + } + + // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header. + if ($expected_dynamic_page_cache_header_value !== FALSE) { + $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache')); + $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]); + } + else { + $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache')); + } + } + + /** + * Asserts that a resource error response has the given message. + * + * @param int $expected_status_code + * The expected response status. + * @param string $expected_title. + * ………………………………………………………………………………………………………………………………………………………………………………………………………………… + * @param string $expected_message + * ………………………………………………………………………………………………………………………………………………………………………………………………………………… + * @param \Psr\Http\Message\ResponseInterface $response + * The error response to assert. + * @param string[]|false $expected_cache_tags + * (optional) The expected cache tags in the X-Drupal-Cache-Tags response + * header, or FALSE if that header should be absent. Defaults to FALSE. + * @param string[]|false $expected_cache_contexts + * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts + * response header, or FALSE if that header should be absent. Defaults to + * FALSE. + * @param string|false $expected_page_cache_header_value + * (optional) The expected X-Drupal-Cache response header value, or FALSE if + * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults + * to FALSE. + * @param string|false $expected_dynamic_page_cache_header_value + * (optional) The expected X-Drupal-Dynamic-Cache response header value, or + * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. + * Defaults to FALSE. + */ + protected function assertResourceErrorResponse($expected_status_code, $expected_title, $expected_message, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { + $expected = [ + 'errors' => [ + [ + 'title' => $expected_title, + 'status' => $expected_status_code, + 'detail' => $expected_message, + 'links' => [ + 'info' => HttpExceptionNormalizer::getInfoUrl($expected_status_code), + ], + 'code' => 0, + ], + ], + ]; + $expected_body = ($expected_message !== FALSE) ? $this->serializer->encode($expected, 'api_json') : FALSE; + // @todo ………………………………………;; + $this->assertResourceResponse($expected_status_code, $expected_body, $response); +// $this->assertResourceResponse($expected_status_code, $expected_body, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value); + } + + /** + * Adds the Xdebug cookie to the request options. + * + * @param array $request_options + * The request options. + * + * @return array + * Request options updated with the Xdebug cookie if present. + */ + protected function decorateWithXdebugCookie(array $request_options) { + $session = $this->getSession(); + $driver = $session->getDriver(); + if ($driver instanceof BrowserKitDriver) { + $client = $driver->getClient(); + foreach ($client->getCookieJar()->all() as $cookie) { + if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) { + $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue(); + } + else { + $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue(); + } + } + } + return $request_options; + } + + protected function makeNormalizationViolateJsonApiSpec(array $normalization, $key) { + unset($normalization['data'][$key]); + return $normalization; + } + + /** + * Makes the given entity normalization invalid. + * + * @param array $normalization + * An entity normalization. + * + * @return array + * The updated entity normalization, now invalid. + */ + protected function makeNormalizationInvalid(array $normalization) { + // Add a second label to this entity to make it invalid. + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $normalization['data']['attributes'][$label_field] = [ + 0 => $normalization['data']['attributes'][$label_field], + 1 => 'Second Title', + ]; + + return $normalization; + } + + /** + * Tests GETting an individual resource, plus edge cases to ensure good DX. + */ + public function testGetIndividual() { + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + $request_options = $this->getAuthenticationRequestOptions('GET'); + + // DX: 403 when unauthorized. + $response = $this->request('GET', $url, $request_options); + $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); + $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS'); + $this->assertArrayNotHasKey('Link', $response->getHeaders()); + + $this->setUpAuthorization('GET'); + + // 200 for well-formed HEAD request. + $response = $this->request('HEAD', $url, $request_options); + $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'MISS'); + $head_headers = $response->getHeaders(); + + // 200 for well-formed GET request. Page Cache hit because of HEAD request. + // Same for Dynamic Page Cache hit. + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, 'HIT'); + // Assert that Dynamic Page Cache did not store a ResourceResponse object, + // which needs serialization after every cache hit. Instead, it should + // contain a flattened response. Otherwise performance suffers. + // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse() + $cache_items = $this->container->get('database') + ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [ + ':pattern' => '%[route]=jsonapi.%', + ]) + ->fetchAllAssoc('cid'); + $this->assertTrue(count($cache_items) >= 2); + $found_cache_redirect = FALSE; + $found_cached_200_response = FALSE; + $other_cached_responses_are_4xx = TRUE; + foreach ($cache_items as $cid => $cache_item) { + $cached_data = unserialize($cache_item->data); + if (!isset($cached_data['#cache_redirect'])) { + $cached_response = $cached_data['#response']; + if ($cached_response->getStatusCode() === 200) { + $found_cached_200_response = TRUE; + } + elseif (!$cached_response->isClientError()) { + $other_cached_responses_are_4xx = FALSE; + } + $this->assertNotInstanceOf(ResourceResponse::class, $cached_response); + $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response); + } + else { + $found_cache_redirect = TRUE; + } + } + $this->assertTrue($found_cache_redirect); + $this->assertTrue($found_cached_200_response); + $this->assertTrue($other_cached_responses_are_4xx); + + // Sort the serialization data first so we can do an identical comparison + // for the keys with the array order the same (it needs to match with + // identical comparison). + $expected = $this->getExpectedNormalizedEntity(); + static::recursiveKSort($expected); + $actual = $this->serializer->decode((string) $response->getBody(), 'api_json'); + static::recursiveKSort($actual); + $this->assertSame($expected, $actual); + + // Not only assert the normalization, also assert deserialization of the + // response results in the expected object. + $unserialized = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), 'api_json', [ + 'target_entity' => static::$entityTypeId, + 'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName), + ]); + // @todo ………………………………………… (deserialization works, but the UUID is set to a random one rather than the received one) +// $this->assertSame($unserialized->uuid(), $this->entity->uuid()); + $get_headers = $response->getHeaders(); + + // Verify that the GET and HEAD responses are the same. The only difference + // is that there's no body. For this reason the 'Transfer-Encoding' and + // 'Vary' headers are also added to the list of headers to ignore, as they + // may be added to GET requests, depending on web server configuration. They + // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'. + $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Cache', 'Transfer-Encoding', 'Vary']; + $header_cleaner = function ($headers) use ($ignored_headers) { + foreach ($headers as $header => $value) { + if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) { + unset($headers[$header]); + } + } + return $headers; + }; + $get_headers = $header_cleaner($get_headers); + $head_headers = $header_cleaner($head_headers); + $this->assertSame($get_headers, $head_headers); + + // @todo Uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932. + /* + // BC: serialization_update_8401(). + // Only run this for fieldable entities. It doesn't make sense for config + // entities as config values always use the raw values (as per the config + // schema), returned directly from the ConfigEntityNormalizer, which + // doesn't deal with fields individually. + if ($this->entity instanceof FieldableEntityInterface) { + // Test the BC settings for timestamp values. + $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', TRUE)->save(TRUE); + // Rebuild the container so new config is reflected in the addition of the + // TimestampItemNormalizer. + $this->rebuildAll(); + + $response = $this->request('GET', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS'); + + // This ensures the BC layer for bc_timestamp_normalizer_unix works as + // expected. This method should be using + // ::formatExpectedTimestampValue() to generate the timestamp value. This + // will take into account the above config setting. + $expected = $this->getExpectedNormalizedEntity(); + // Config entities are not affected. + // @see \Drupal\serialization\Normalizer\ConfigEntityNormalizer::normalize() + static::recursiveKSort($expected); + $actual = $this->serializer->decode((string) $response->getBody(), 'api_json'); + static::recursiveKSort($actual); + $this->assertSame($expected, $actual); + + // Reset the config value and rebuild. + $this->config('serialization.settings')->set('bc_timestamp_normalizer_unix', FALSE)->save(TRUE); + $this->rebuildAll(); + } + */ + + // DX: 404 when GETting non-existing entity, but HTML response. + $random_uuid = \Drupal::service('uuid')->generate(); + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $random_uuid]); + $response = $this->request('GET', $url, $request_options); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + + // DX: 404 JSON API response if the ?_format query string is present. + $url->setOption('query', ['_format' => 'api_json']); + $response = $this->request('GET', $url, $request_options); + $path = str_replace($random_uuid, '{' . static::$entityTypeId . '}', $url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); + $message = 'The "' . static::$entityTypeId . '" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")'; + $this->assertResourceErrorResponse(404, 'Not Found', $message, $response); + } + + /** + * Tests POSTing an individual resource, plus edge cases to ensure good DX. + */ + public function testPostIndividual() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); + return; + } + + // Try with all of the following request bodies. + $unparseable_request_body = '!{>}<'; + $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json'); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity(), 'api_json'); + $parseable_invalid_request_body_missing_type = $this->serializer->encode($this->makeNormalizationViolateJsonApiSpec($this->getNormalizedPostEntity(), 'type'), 'api_json'); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPostEntity()), 'api_json'); + $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPostEntity() + ['uuid' => [$this->randomMachineName(129)]], 'api_json'); + // @todo … +// $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPostEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json'); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + $url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + $request_options = $this->getAuthenticationRequestOptions('POST'); + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('POST'), $response); + + // @todo JSON API doesn't handle any of these edge cases correctly. + /* + // DX: 415 when no Content-Type request header. HTML response because + // missing ?_format query string. + $response = $this->request('POST', $url, $request_options); + $this->assertSame(415, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertContains('A client error happened', (string) $response->getBody()); + + $url->setOption('query', ['_format' => 'api_json']); + + // DX: 415 when no Content-Type request header. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(415, '…', 'No "Content-Type" request header specified', $response); + + $request_options[RequestOptions::HEADERS]['Content-Type'] = ''; + + // DX: 400 when no request body. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(400, 'No entity content received.', $response); + + $request_options[RequestOptions::BODY] = $unparseable_request_body; + + // DX: 400 when unparseable request body. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(400, 'df', 'Syntax error', $response); +*/ + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + +// if (static::$auth) { +// // DX: forgetting authentication: authentication provider-specific error +// // response. +// $response = $this->request('POST', $url, $request_options); +// $this->assertResponseWhenMissingAuthentication('POST', $response); +// } +// +// $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('POST'), $response); + + $this->setUpAuthorization('POST'); + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type; + + // DX: 400 when invalid JSON API request body. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(400, 'Bad Request', 'Resource object must include a "type".', $response); + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $expected = [ + 'errors' => [ + [ + 'title' => 'Unprocessable Entity', + 'status' => 422, + 'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", + 'code' => 0, + 'source' => [ + 'pointer' => '/data/attributes/' . $label_field, + ], + ], + ], + ]; + $this->assertResourceResponse(422, json_encode($expected), $response); +// $this->assertResourceErrorResponse(422, 'Unprocessable Entity', "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", $response); + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; + + // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934386 lands. + /* + // DX: 422 when invalid entity: UUID field too long. + // @todo Fix this in https://www.drupal.org/node/2149851. + if ($this->entity->getEntityType()->hasKey('uuid')) { + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response); + } + */ +// +// $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; +// +// // DX: 403 when entity contains field without 'edit' access. +// $response = $this->request('POST', $url, $request_options); +// $this->assertResourceErrorResponse(403, "Access denied on creating field 'field_rest_test'.", $response); + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands. + /* + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; + + // DX: 415 when request body in existing but not allowed format. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response); + */ + + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response, Cache::mergeTags(['http_response', static::$entityTypeId . ':' . static::$firstCreatedEntityId], []), $this->getExpectedCacheContexts()); + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entityStorage->load(static::$firstCreatedEntityId)->uuid()])->setAbsolute(TRUE)->toString(); + //$location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); + $this->assertSame([$location], $response->getHeader('Location')); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + // If the entity is stored, perform extra checks. + if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { + // Assert that the entity was indeed created, and that the response body + // contains the serialized created entity. + $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId); + $created_entity_normalization = $this->entityToJsonApi->normalize($created_entity); + // @todo Remove this if-test in https://www.drupal.org/node/2543726: execute + // its body unconditionally. + if (static::$entityTypeId !== 'taxonomy_term') { + $decoded_response_body = $this->serializer->decode((string) $response->getBody(), 'api_json'); + // @todo Remove the two lines below once https://www.drupal.org/project/jsonapi/issues/2925043 lands. + unset($created_entity_normalization['links']); + unset($decoded_response_body['links']); + $this->assertSame($created_entity_normalization, $decoded_response_body); + } + // Assert that the entity was indeed created using the POSTed values. + foreach ($this->getNormalizedPostEntity()['data']['attributes'] as $field_name => $field_normalization) { + $this->assertSame($field_normalization, $created_entity_normalization['data']['attributes'][$field_name]); + } + } + } + + + /** + * Tests PATCHing an individual resource, plus edge cases to ensure good DX. + */ + public function testPatchIndividual() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.'); + return; + } + + // Try with all of the following request bodies. + $unparseable_request_body = '!{>}<'; + $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json'); + $parseable_valid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity(), 'api_json'); + $parseable_invalid_request_body = $this->serializer->encode($this->makeNormalizationInvalid($this->getNormalizedPatchEntity()), 'api_json'); +// $parseable_invalid_request_body_2 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => [['value' => $this->randomString()]]], 'api_json'); + // The 'field_rest_test' field does not allow 'view' access, so does not end + // up in the normalization. Even when we explicitly add it the normalization + // that we send in the body of a PATCH request, it is considered invalid. +// $parseable_invalid_request_body_3 = $this->serializer->encode($this->getNormalizedPatchEntity() + ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()], 'api_json'); + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + $request_options = $this->getAuthenticationRequestOptions('PATCH');; + + // @todo JSON API doesn't handle any of these edge cases correctly. + /* + // DX: 415 when no Content-Type request header. + $response = $this->request('PATCH', $url, $request_options); + $this->assertSame(415, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertContains('A client error happened', (string) $response->getBody()); + + $url->setOption('query', ['_format' => static::$format]); + + // DX: 415 when no Content-Type request header. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response); + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + // DX: 400 when no request body. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(400, 'No entity content received.', $response); + + $request_options[RequestOptions::BODY] = $unparseable_request_body; + + // DX: 400 when unparseable request body. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(400, 'Syntax error', $response); +*/ + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + +// if (static::$auth) { +// // DX: forgetting authentication: authentication provider-specific error +// // response. +// $response = $this->request('PATCH', $url, $request_options); +// $this->assertResponseWhenMissingAuthentication('PATCH', $response); +// } +// +// $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('PATCH')); + + // DX: 403 when unauthorized. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('PATCH'), $response); + + $this->setUpAuthorization('PATCH'); + + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('PATCH', $url, $request_options); + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $expected = [ + 'errors' => [ + [ + 'title' => 'Unprocessable Entity', + 'status' => 422, + 'detail' => "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", + 'code' => 0, + 'source' => [ + 'pointer' => '/data/attributes/' . $label_field, + ], + ], + ], + ]; + $this->assertResourceResponse(422, json_encode($expected), $response); + +/* + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; + + // DX: 403 when entity contains field without 'edit' access. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response); + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; + + // DX: 403 when entity contains field without 'edit' nor 'view' access, even + // when the value for that field matches the current value. This is allowed + // in principle, but leads to information disclosure. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response); +*/ + + // DX: 403 when sending PATCH request with updated read-only fields. + list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity); + // Send PATCH request by serializing the modified entity, assert the error + // response, change the modified entity field that caused the error response + // back to its original value, repeat. + foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) { + $request_options[RequestOptions::BODY] = $this->entityToJsonApi->serialize($modified_entity); + $response = $this->request('PATCH', $url, $request_options); + $expected = [ + 'errors' => [ + [ + 'title' => 'Forbidden', + 'status' => 403, + 'detail' => "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), + 'links' => [ + 'info' => HttpExceptionNormalizer::getInfoUrl(403), + ], + 'code' => 0, + 'id' => '/' . static::$resourceTypeName . '/' . $this->entity->uuid(), + 'source' => [ + 'pointer' => '/data/attributes/' . $patch_protected_field_name, + ], + ], + ], + ]; + $this->assertResourceResponse(403, Json::encode($expected), $response); + $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]); + } + + // 200 for well-formed PATCH request that sends all fields (even including + // read-only ones, but with unchanged values). + $valid_request_body = NestedArray::mergeDeep($this->entityToJsonApi->normalize($this->entity), $this->getNormalizedPatchEntity()); + // @todo Remove this foreach in https://www.drupal.org/project/jsonapi/issues/2939810. + foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) { + unset($valid_request_body['data']['attributes'][$field_name]); + } + $request_options[RequestOptions::BODY] = json_encode($valid_request_body); + $response = $this->request('PATCH', $url, $request_options); + // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939 + $this->assertResourceResponse(200, FALSE, $response, Cache::mergeTags(['http_response', $this->entity->getCacheTags()[0]], []), ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site', 'user.permissions']); + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + // @todo Uncomment when https://www.drupal.org/project/jsonapi/issues/2934149 lands. + /* + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; + + // DX: 415 when request body in existing but not allowed format. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response); + */ + + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; + + // 200 for well-formed request. + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response, Cache::mergeTags(['http_response', $this->entity->getCacheTags()[0]], []), ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site', 'user.permissions']); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + // Assert that the entity was indeed updated, and that the response body + // contains the serialized updated entity. + $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id()); + $updated_entity_normalization = $this->entityToJsonApi->normalize($updated_entity); + $this->assertSame($updated_entity_normalization, Json::decode((string) $response->getBody())); + // Assert that the entity was indeed created using the PATCHed values. + foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) { + // Some top-level keys in the normalization may not be fields on the + // entity (for example '_links' and '_embedded' in the HAL normalization). + if ($updated_entity->hasField($field_name)) { + // Subset, not same, because we can e.g. send just the target_id for the + // bundle in a PATCH request; the response will include more properties. + $this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE); + } + } +/* + // Ensure that fields do not get deleted if they're not present in the PATCH + // request. Test this using the configurable field that we added, but which + // is not sent in the PATCH request. + $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); + + // Multi-value field: remove item 0. Then item 1 becomes item 0. + $normalization_multi_value_tests = $this->getNormalizedPatchEntity(); + $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue(); + $normalization_remove_item = $normalization_multi_value_tests; + unset($normalization_remove_item['field_rest_test_multivalue'][0]); + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_remove_item, static::$format); + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + $this->assertSame([0 => ['value' => 'Two']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue()); + + // Multi-value field: add one item before the existing one, and one after. + $normalization_add_items = $normalization_multi_value_tests; + $normalization_add_items['field_rest_test_multivalue'][2] = ['value' => 'Three']; + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization_add_items, static::$format); + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + $this->assertSame([0 => ['value' => 'One'], 1 => ['value' => 'Two'], 2 => ['value' => 'Three']], $this->entityStorage->loadUnchanged($this->entity->id())->get('field_rest_test_multivalue')->getValue()); +*/ + } + + /** + * Tests DELETEing an individual resource, plus edge cases to ensure good DX. + */ + public function testDeleteIndividual() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.'); + return; + } + + // The URL and Guzzle request options that will be used in this test. The + // request options will be modified/expanded throughout this test: + // - to first test all mistakes a developer might make, and assert that the + // error responses provide a good DX + // - to eventually result in a well-formed request that succeeds. + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + $request_options = $this->getAuthenticationRequestOptions('PATCH'); + + // DX: 403 when unauthorized. + $response = $this->request('DELETE', $url, $request_options); + $this->assertResourceErrorResponse(403, 'Forbidden', $this->getExpectedUnauthorizedAccessMessage('DELETE'), $response); + + $this->setUpAuthorization('DELETE'); + + // 204 for well-formed request. + $response = $this->request('DELETE', $url, $request_options); + // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939 + $this->assertResourceResponse(204, '', $response, ['http_response'], ['user.permissions']); + } + + /** + * Transforms a normalization: casts all non-string types to strings. + * + * @param array $normalization + * A normalization to transform. + * + * @return array + * The transformed normalization. + */ + protected static function castToString(array $normalization) { + foreach ($normalization as $key => $value) { + if (is_bool($value)) { + $normalization[$key] = (string) (int) $value; + } + elseif (is_int($value) || is_float($value)) { + $normalization[$key] = (string) $value; + } + elseif (is_array($value)) { + $normalization[$key] = static::castToString($value); + } + } + return $normalization; + } + + /** + * Recursively sorts an array by key. + * + * @param array $array + * An array to sort. + * + * @return array + * The sorted array. + */ + protected static function recursiveKSort(array &$array) { + // First, sort the main array. + ksort($array); + + // Then check for child arrays. + foreach ($array as $key => &$value) { + if (is_array($value)) { + static::recursiveKSort($value); + } + } + } + + /** + * Returns Guzzle request options for authentication. + * + * @param string $method + * The HTTP method for this authenticated request. + * + * @return array + * Guzzle request options to use for authentication. + * + * @see \GuzzleHttp\ClientInterface::request() + */ + protected function getAuthenticationRequestOptions($method) { + return [ + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), + ], + ]; + } + + /** + * Clones the given entity and modifies all PATCH-protected fields. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity being tested and to modify. + * + * @return array + * Contains two items: + * 1. The modified entity object. + * 2. The original field values, keyed by field name. + * + * @internal + */ + protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) { + $modified_entity = clone $entity; + $original_values = []; + foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) { + $field = $modified_entity->get($field_name); + $original_values[$field_name] = $field->getValue(); + switch ($field->getItemDefinition()->getClass()) { + case EntityReferenceItem::class: + // EntityReferenceItem::generateSampleValue() picks one of the last 50 + // entities of the supported type & bundle. We don't care if the value + // is valid, we only care that it's different. + $field->setValue(['target_id' => 99999]); + break; + case BooleanItem::class: + // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50% + // chance of not picking a different value. + $field->value = ((int) $field->value) === 1 ? '0' : '1'; + break; + case PathItem::class: + // PathItem::generateSampleValue() doesn't set a PID, which causes + // PathItem::postSave() to fail. Keep the PID (and other properties), + // just modify the alias. + $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3))); + break; + default: + $original_field = clone $field; + while ($field->equals($original_field)) { + $field->generateSampleItems(); + } + break; + } + } + + return [$modified_entity, $original_values]; + } + +} \ No newline at end of file diff --git a/tests/src/Functional/TermTest.php b/tests/src/Functional/TermTest.php new file mode 100644 index 0000000..4394791 --- /dev/null +++ b/tests/src/Functional/TermTest.php @@ -0,0 +1,372 @@ + NULL, + ]; + + /** + * @var \Drupal\taxonomy\TermInterface + */ + protected $entity; + + /** + * {@inheritdoc} + */ + protected function setUpAuthorization($method) { + // @todo Remove this in https://www.drupal.org/project/jsonapi/issues/2940336. + $this->grantPermissionsToTestedRole(['access content']); + + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + + case 'POST': + $this->grantPermissionsToTestedRole(['create terms in camelids']); + break; + + case 'PATCH': + // Grant the 'create url aliases' permission to test the case when + // the path field is accessible, see + // \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase + // for a negative test. + $this->grantPermissionsToTestedRole(['edit terms in camelids', 'create url aliases']); + break; + + case 'DELETE': + $this->grantPermissionsToTestedRole(['delete terms in camelids']); + break; + } + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $vocabulary = Vocabulary::load('camelids'); + if (!$vocabulary) { + // Create a "Camelids" vocabulary. + $vocabulary = Vocabulary::create([ + 'name' => 'Camelids', + 'vid' => 'camelids', + ]); + $vocabulary->save(); + } + + // Create a "Llama" taxonomy term. + $term = Term::create(['vid' => $vocabulary->id()]) + ->setName('Llama') + ->setDescription("It is a little known fact that llamas cannot count higher than seven.") + ->setChangedTime(123456789) + ->set('path', '/llama'); + $term->save(); + + return $term; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $self_url = Url::fromUri('base:/jsonapi/taxonomy_term/camelids/' . $this->entity->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl(); + + // We test with multiple parent terms, and combinations thereof. + // @see ::createEntity() + // @see ::testGetIndividual() + // @see ::testGetIndividualTermWithParent() + // @see ::providerTestGetIndividualTermWithParent() + $parent_term_ids = []; + for ($i = 0; $i < $this->entity->get('parent')->count(); $i++) { + $parent_term_ids[$i] = (int) $this->entity->get('parent')[$i]->target_id; + } + + $expected_parent_normalization = FALSE; + switch ($parent_term_ids) { + case [0]: + // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339 + $expected_parent_normalization = [ + 'data' => [], + ]; + break; + case [2]: + $expected_parent_normalization = [ + 'data' => [ + [ + 'id' => Term::load(2)->uuid(), + 'type' => 'taxonomy_term--camelids', + ], + ], + 'links' => [ + 'related' => $self_url . '/parent', + 'self' => $self_url . '/relationships/parent', + ], + ]; + break; + case [0, 2]: + $expected_parent_normalization = [ + 'data' => [ + // @todo This is missing the root parent, fix this in https://www.drupal.org/project/jsonapi/issues/2940339 + [ + 'id' => Term::load(2)->uuid(), + 'type' => 'taxonomy_term--camelids', + ], + ], + 'links' => [ + 'related' => $self_url . '/parent', + 'self' => $self_url . '/relationships/parent', + ], + ]; + break; + case [3, 2]: + $expected_parent_normalization = [ + 'data' => [ + [ + 'id' => Term::load(3)->uuid(), + 'type' => 'taxonomy_term--camelids', + ], + [ + 'id' => Term::load(2)->uuid(), + 'type' => 'taxonomy_term--camelids', + ], + ], + 'links' => [ + 'related' => $self_url . '/parent', + 'self' => $self_url . '/relationships/parent', + ], + ]; + break; + } + return [ + 'jsonapi' => [ + 'meta' => [ + 'links' => [ + 'self' => 'http://jsonapi.org/format/1.0/', + ], + ], + 'version' => '1.0', + ], + 'links' => [ + 'self' => $self_url, + ], + 'data' => [ + 'id' => $this->entity->uuid(), + 'type' => 'taxonomy_term--camelids', + 'links' => [ + 'self' => $self_url, + ], + 'attributes' => [ + 'changed' => $this->entity->getChangedTime(), + // @todo uncomment this in https://www.drupal.org/project/jsonapi/issues/2929932 +// 'changed' => $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()), + 'default_langcode' => TRUE, + 'description' => [ + 'value' => 'It is a little known fact that llamas cannot count higher than seven.', + 'format' => NULL, + // @todo Uncomment in https://www.drupal.org/project/jsonapi/issues/2921257. +// 'processed' => "

It is a little known fact that llamas cannot count higher than seven.

\n", + ], + 'langcode' => 'en', + 'name' => 'Llama', + 'path' => [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + ], + 'tid' => 1, + 'uuid' => $this->entity->uuid(), + 'weight' => 0, + ], + 'relationships' => [ + 'parent' => $expected_parent_normalization, + 'vid' => [ + 'data' => [ + 'id' => Vocabulary::load('camelids')->uuid(), + 'type' => 'taxonomy_vocabulary--taxonomy_vocabulary', + ], + 'links' => [ + 'related' => $self_url . '/vid', + 'self' => $self_url . '/relationships/vid', + ], + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return [ + 'data' => [ + 'type' => 'term--camelids', + 'attributes' => [ + 'name' => 'Dramallama', + 'description' => [ + 'value' => 'Dramallamas are the coolest camelids.', + 'format' => NULL, + ], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessMessage($method) { + // @todo Remove this in + return "The 'access content' permission is required."; + + switch ($method) { + case 'GET': + return "The 'access content' permission is required."; + case 'POST': + return "The following permissions are required: 'create terms in camelids' OR 'administer taxonomy'."; + case 'PATCH': + return "The following permissions are required: 'edit terms in camelids' OR 'administer taxonomy'."; + case 'DELETE': + return "The following permissions are required: 'delete terms in camelids' OR 'administer taxonomy'."; + default: + return parent::getExpectedUnauthorizedAccessMessage($method); + } + } + + /** + * Tests PATCHing a term's path. + * + * For a negative test, see the similar test coverage for Node. + * + * @see \Drupal\Tests\jsonapi\Functional\NodeTest::testPatchPath() + * @see \Drupal\Tests\rest\Functional\EntityResource\Node\NodeResourceTestBase::testPatchPath() + */ + public function testPatchPath() { + $this->setUpAuthorization('GET'); + $this->setUpAuthorization('PATCH'); + + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + + // GET term's current normalization. + $response = $this->request('GET', $url, $this->getAuthenticationRequestOptions('GET')); + $normalization = Json::decode((string) $response->getBody()); + + // Change term's path alias. + $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world'; + + // Create term PATCH request. + $request_options = $this->getAuthenticationRequestOptions('PATCH'); + $request_options[RequestOptions::BODY] = Json::encode($normalization); + + // PATCH request: 200. + $response = $this->request('PATCH', $url, $request_options); + // @todo investigate this more (cache tags + contexts), cfr https://www.drupal.org/project/drupal/issues/2626298 + https://www.drupal.org/project/jsonapi/issues/2933939 + $this->assertResourceResponse(200, FALSE, $response, ['http_response', 'taxonomy_term:1'], ['url.query_args:fields', 'url.query_args:filter', 'url.query_args:include', 'url.query_args:page', 'url.query_args:sort', 'url.site', 'user.permissions']); + $updated_normalization = Json::decode((string) $response->getBody()); + $this->assertSame($normalization['data']['attributes']['path']['alias'], $updated_normalization['data']['attributes']['path']['alias']); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheTags() { + // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342. +// return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:filter.format.plain_text', 'config:filter.settings']); + return parent::getExpectedCacheTags(); + } + + /** + * {@inheritdoc} + */ + protected function getExpectedCacheContexts() { + // @todo Uncomment first line, remove second line in https://www.drupal.org/project/jsonapi/issues/2940342. +// return Cache::mergeContexts(['url.site'], $this->container->getParameter('renderer.config')['required_cache_contexts']); + return parent::getExpectedCacheContexts(); + } + + /** + * Tests GETting a term with a parent term other than the default (0). + * + * @see ::getExpectedNormalizedEntity() + * + * @dataProvider providerTestGetIndividualTermWithParent + */ + public function testGetIndividualTermWithParent(array $parent_term_ids) { + // Create all possible parent terms. + Term::create(['vid' => Vocabulary::load('camelids')->id()]) + ->setName('Lamoids') + ->save(); + Term::create(['vid' => Vocabulary::load('camelids')->id()]) + ->setName('Wimoids') + ->save(); + + // Modify the entity under test to use the provided parent terms. + $this->entity->set('parent', $parent_term_ids)->save(); + + // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. + $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), [static::$entityTypeId => $this->entity->uuid()]); + //$url = $this->entity->toUrl('jsonapi'); + $request_options = $this->getAuthenticationRequestOptions('GET'); + $this->setUpAuthorization('GET'); + $response = $this->request('GET', $url, $request_options); + $expected = $this->getExpectedNormalizedEntity(); + static::recursiveKSort($expected); + $actual = Json::decode((string) $response->getBody()); + static::recursiveKSort($actual); + $this->assertSame($expected, $actual); + } + + public function providerTestGetIndividualTermWithParent() { + return [ + 'root parent: [0] (= no parent)' => [ + [0] + ], + 'non-root parent: [2]' => [ + [2] + ], + 'multiple parents: [0,2] (root + non-root parent)' => [ + [0, 2] + ], + 'multiple parents: [3,2] (both non-root parents)' => [ + [3, 2] + ], + ]; + } + +}