diff --git a/modules/jsonapi/jsonapi.info.yml b/modules/jsonapi/jsonapi.info.yml index 36bb15c..cd3b601 100644 --- a/modules/jsonapi/jsonapi.info.yml +++ b/modules/jsonapi/jsonapi.info.yml @@ -1,6 +1,6 @@ name: JSON API type: module -description: Provides a JSON API format for the REST resources. +description: Provides a JSON API standards-compliant API for accessing and manipulating Drupal content and configuration entities. core: 8.x package: Web services dependencies: diff --git a/modules/jsonapi/jsonapi.services.yml b/modules/jsonapi/jsonapi.services.yml index f42e648..c111a8e 100644 --- a/modules/jsonapi/jsonapi.services.yml +++ b/modules/jsonapi/jsonapi.services.yml @@ -9,6 +9,11 @@ services: arguments: ['@current_user'] tags: - { name: normalizer, priority: 2 } + serializer.normalizer.entity_access_exception.jsonapi: + class: Drupal\jsonapi\Normalizer\EntityAccessDeniedHttpExceptionNormalizer + arguments: ['@current_user'] + tags: + - { name: normalizer, priority: 2 } serializer.normalizer.scalar.jsonapi: class: Drupal\jsonapi\Normalizer\ScalarNormalizer tags: @@ -43,7 +48,7 @@ services: - { name: normalizer, priority: 21 } serializer.normalizer.jsonapi_document_toplevel.jsonapi: class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer - arguments: ['@jsonapi.link_manager', '@jsonapi.current_context', '@entity_type.manager'] + arguments: ['@jsonapi.link_manager', '@jsonapi.current_context', '@entity_type.manager', '@entity.repository'] tags: - { name: normalizer, priority: 22 } serializer.normalizer.entity_reference_field.jsonapi: @@ -79,10 +84,10 @@ services: jsonapi.field_resolver: class: Drupal\jsonapi\Context\FieldResolver arguments: ['@jsonapi.current_context', '@entity_field.manager'] - access_check.jsonapi.custom_parameter_names: - class: Drupal\jsonapi\Access\CustomParameterNames + access_check.jsonapi.custom_query_parameter_names: + class: Drupal\jsonapi\Access\CustomQueryParameterNamesAccessCheck tags: - - { name: access_check, applies_to: _custom_parameter_names } + - { name: access_check, applies_to: _jsonapi_custom_query_parameter_names } paramconverter.jsonapi.entity_uuid: class: Drupal\jsonapi\ParamConverter\EntityUuidConverter tags: diff --git a/modules/jsonapi/src/Access/CustomParameterNames.php b/modules/jsonapi/src/Access/CustomParameterNames.php deleted file mode 100644 index a8ec382..0000000 --- a/modules/jsonapi/src/Access/CustomParameterNames.php +++ /dev/null @@ -1,61 +0,0 @@ -attributes->get('_json_api_params', []); - if (!$this->validate($json_api_params)) { - return AccessResult::forbidden(); - } - return AccessResult::allowed(); - } - - /** - * Validates the JSONAPI parameters. - * - * @see http://jsonapi.org/format/#document-member-names-reserved-characters - * - * @param string[] $json_api_params - * The JSONAPI parameters. - * - * @return bool - */ - protected function validate(array $json_api_params) { - $valid = TRUE; - - foreach (array_keys($json_api_params) as $name) { - if (strpbrk($name, "+,.[]!”#$%&’()*/:;<=>?@\\^`{}~|\x0\x1\x2\x3\x4\x5\x6\x7\x8\x9\xA\xB\xC\xD\xE\xF\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F")) { - $valid = FALSE; - break; - } - - if (strpbrk($name[0], '-_ ') || strpbrk($name[strlen($name) - 1], '-_ ')) { - $valid = FALSE; - break; - } - } - - return $valid; - } - -} diff --git a/modules/jsonapi/src/Context/CurrentContext.php b/modules/jsonapi/src/Context/CurrentContext.php index bcd42a1..9f5c29f 100644 --- a/modules/jsonapi/src/Context/CurrentContext.php +++ b/modules/jsonapi/src/Context/CurrentContext.php @@ -97,11 +97,13 @@ class CurrentContext { * The JSON API provided parameter. */ public function getJsonApiParameter($parameter_key) { - return $this + $params = $this ->requestStack ->getCurrentRequest() ->attributes - ->get("_json_api_params[$parameter_key]", NULL, TRUE); + ->get('_json_api_params'); + + return isset($params[$parameter_key]) ? $params[$parameter_key] : NULL; } /** diff --git a/modules/jsonapi/src/Controller/EntityResource.php b/modules/jsonapi/src/Controller/EntityResource.php index 907171b..363a622 100644 --- a/modules/jsonapi/src/Controller/EntityResource.php +++ b/modules/jsonapi/src/Controller/EntityResource.php @@ -4,6 +4,7 @@ namespace Drupal\jsonapi\Controller; use Drupal\Component\Serialization\Json; use Drupal\Core\Access\AccessibleInterface; +use Drupal\Core\Access\AccessResultReasonInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -15,6 +16,7 @@ use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException; use Drupal\jsonapi\Resource\EntityCollection; use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel; use Drupal\jsonapi\ResourceType\ResourceType; @@ -117,7 +119,7 @@ class EntityResource { public function getIndividual(EntityInterface $entity, Request $request, $response_code = 200) { $entity_access = $entity->access('view', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to GET the selected resource.'); + throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to GET the selected resource.'); } $response = $this->buildWrappedResponse($entity, $response_code); return $response; @@ -169,7 +171,7 @@ class EntityResource { $entity_access = $entity->access('create', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to POST the selected resource.'); + throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to POST the selected resource.'); } $this->validate($entity); $entity->save(); @@ -192,7 +194,7 @@ class EntityResource { public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) { $entity_access = $entity->access('update', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to PATCH the selected resource.'); + throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to PATCH the selected resource.'); } $body = Json::decode($request->getContent()); $data = $body['data']; @@ -229,7 +231,7 @@ class EntityResource { public function deleteIndividual(EntityInterface $entity, Request $request) { $entity_access = $entity->access('delete', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to DELETE the selected resource.'); + throw new EntityAccessDeniedHttpException($entity, $entity_access, '/data', 'The current user is not allowed to DELETE the selected resource.'); } $entity->delete(); return new ResourceResponse(NULL, 204); @@ -248,7 +250,8 @@ class EntityResource { // Instantiate the query for the filtering. $entity_type_id = $this->resourceType->getEntityTypeId(); - $params = $request->attributes->get('_route_params[_json_api_params]', NULL, TRUE); + $route_params = $request->attributes->get('_route_params'); + $params = isset($route_params['_json_api_params']) ? $route_params['_json_api_params'] : []; $query = $this->getCollectionQuery($entity_type_id, $params); $results = $query->execute(); @@ -386,7 +389,8 @@ class EntityResource { $field_access = $field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_list->getName())); + $field_name = $field_list->getName(); + throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); } // Time to save the relationship. foreach ($parsed_field_list as $field_item) { @@ -464,7 +468,7 @@ class EntityResource { $field_name = $parsed_field_list->getName(); $field_access = $parsed_field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); } $entity->{$field_name} = $parsed_field_list; } @@ -501,7 +505,7 @@ class EntityResource { $field_name = $parsed_field_list->getName(); $field_access = $parsed_field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + throw new EntityAccessDeniedHttpException($entity, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); } /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ $field_list = $entity->{$related_field}; @@ -623,7 +627,8 @@ class EntityResource { /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ $entity_access = $entity->access('update', NULL, TRUE); if (!$entity_access->isAllowed()) { - throw new SerializableHttpException(403, 'The current user is not allowed to update the selected resource.'); + // @todo Is this really the right path? + throw new EntityAccessDeniedHttpException($entity, $entity_access, $related_field, 'The current user is not allowed to update the selected resource.'); } if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) { throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field)); @@ -655,7 +660,7 @@ class EntityResource { if ($destination_field_list->getValue() != $origin_field_list->getValue()) { $field_access = $destination_field_list->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { - throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $destination_field_list->getName())); + throw new EntityAccessDeniedHttpException($destination, $field_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); } $destination->{$field_name} = $origin->get($field_name); } @@ -715,6 +720,9 @@ class EntityResource { * - access: the access object. */ public static function getEntityAndAccess(EntityInterface $entity) { + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + $entity_repository = \Drupal::service('entity.repository'); + $entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']); $access = $entity->access('view', NULL, TRUE); // Accumulate the cacheability metadata for the access. $output = [ @@ -723,11 +731,7 @@ class EntityResource { ]; if ($entity instanceof AccessibleInterface && !$access->isAllowed()) { // Pass an exception to the list of things to normalize. - $output['entity'] = new SerializableHttpException(403, sprintf( - 'Access checks failed for entity %s:%s.', - $entity->getEntityTypeId(), - $entity->id() - )); + $output['entity'] = new EntityAccessDeniedHttpException($entity, $access, '/data', 'The current user is not allowed to GET the selected resource.'); } return $output; diff --git a/modules/jsonapi/src/Controller/RequestHandler.php b/modules/jsonapi/src/Controller/RequestHandler.php index c318b5a..4c4fb5a 100644 --- a/modules/jsonapi/src/Controller/RequestHandler.php +++ b/modules/jsonapi/src/Controller/RequestHandler.php @@ -2,6 +2,7 @@ namespace Drupal\jsonapi\Controller; +use Drupal\Component\Serialization\Json; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Routing\RouteMatchInterface; @@ -165,6 +166,8 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter $response->addCacheableDependency($this->container->get('config.factory') ->get('jsonapi.resource_info')); + assert('$this->validateResponse($response)', 'A JSON API response failed validation (see the logs for details). Please report this in the issue queue on drupal.org'); + return $response; } @@ -275,15 +278,55 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter $field_manager = $this->container->get('entity_field.manager'); /* @var \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager */ $plugin_manager = $this->container->get('plugin.manager.field.field_type'); + /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */ + $entity_repository = $this->container->get('entity.repository'); $resource = new EntityResource( $resource_type_repository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')), $entity_type_manager, $query_builder, $field_manager, $current_context, - $plugin_manager + $plugin_manager, + $entity_repository ); return $resource; } + /** + * Validates a response against the JSON API specification. + * + * @param \Drupal\jsonapi\ResourceResponse $response + * The response to validate. + * + * @return bool + * FALSE if the response failed validation, otherwise TRUE. + */ + protected static function validateResponse(ResourceResponse $response) { + if (!class_exists("\\JsonSchema\\Validator")) { + return TRUE; + } + // Do not use Json::decode here since it coerces the response into an + // associative array, which creates validation errors. + $response_data = json_decode($response->getContent()); + if (empty($response_data)) { + return TRUE; + } + + $validator = new \JsonSchema\Validator; + $schema_path = DRUPAL_ROOT . '/' . drupal_get_path('module', 'jsonapi') . '/schema.json'; + + $validator->check($response_data, (object)['$ref' => 'file://' . $schema_path]); + + if (!$validator->isValid()) { + \Drupal::logger('jsonapi')->debug('Response failed validation: @data', [ + '@data' => Json::encode($response_data), + ]); + \Drupal::logger('jsonapi')->debug('Validation errors: @errors', [ + '@errors' => Json::encode($validator->getErrors()), + ]); + } + + return $validator->isValid(); + } + } diff --git a/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php index bdddd71..baab98c 100644 --- a/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php +++ b/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php @@ -86,7 +86,10 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer implements Denormal ->getFieldStorageDefinition() ->getCardinality(); $entity_collection = new EntityCollection(array_map(function ($item) { - return $item->get('entity')->getValue(); + // Get the referenced entity. + $entity = $item->get('entity')->getValue(); + // And get the translation in the requested language. + return $this->entityRepository->getTranslationFromContext($entity); }, (array) $field->getIterator())); $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $cardinality, $entity_collection, $field->getEntity(), $main_property); return $this->serializer->normalize($relationship, $format, $context); diff --git a/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php b/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php index 628c9d8..2f9e6ce 100644 --- a/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php +++ b/modules/jsonapi/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php @@ -5,7 +5,7 @@ namespace Drupal\jsonapi\Normalizer\Value; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\RefinableCacheableDependencyInterface; use Drupal\Core\Cache\RefinableCacheableDependencyTrait; -use Drupal\jsonapi\RequestCacheabilityDependency; +use Drupal\jsonapi\JsonApiSpec; /** * @internal @@ -74,7 +74,9 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface, $this->values = $values; array_walk($values, [$this, 'addCacheableDependency']); // Make sure that different sparse fieldsets are cached differently. - $this->addCacheableDependency(new RequestCacheabilityDependency()); + $this->addCacheContexts(array_map(function ($query_parameter_name) { + return sprintf('url.query_args:%s', $query_parameter_name); + }, JsonApiSpec::getReservedQueryParameters())); $this->context = $context; $this->isCollection = $is_collection; diff --git a/modules/jsonapi/src/RequestCacheabilityDependency.php b/modules/jsonapi/src/RequestCacheabilityDependency.php deleted file mode 100644 index 2825c7b..0000000 --- a/modules/jsonapi/src/RequestCacheabilityDependency.php +++ /dev/null @@ -1,34 +0,0 @@ -setRequirement('_bundle', $resource_type->getBundle()) ->setRequirement('_permission', 'access content') ->setRequirement('_format', 'api_json') - ->setRequirement('_custom_parameter_names', 'TRUE') + ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE') ->setOption('serialization_class', JsonApiDocumentTopLevel::class) ->setMethods(['GET', 'POST']); $route_collection->addOptions($options); @@ -114,7 +114,7 @@ class Routes implements ContainerInjectionInterface { ->setRequirement('_bundle', $resource_type->getBundle()) ->setRequirement('_permission', 'access content') ->setRequirement('_format', 'api_json') - ->setRequirement('_custom_parameter_names', 'TRUE') + ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE') ->setOption('parameters', $parameters) ->setOption('_auth', $this->authProviderList()) ->setOption('serialization_class', JsonApiDocumentTopLevel::class) @@ -128,7 +128,7 @@ class Routes implements ContainerInjectionInterface { ->setRequirement('_bundle', $resource_type->getBundle()) ->setRequirement('_permission', 'access content') ->setRequirement('_format', 'api_json') - ->setRequirement('_custom_parameter_names', 'TRUE') + ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE') ->setOption('parameters', $parameters) ->setOption('_auth', $this->authProviderList()) ->setMethods(['GET']); @@ -141,7 +141,7 @@ class Routes implements ContainerInjectionInterface { ->setRequirement('_bundle', $resource_type->getBundle()) ->setRequirement('_permission', 'access content') ->setRequirement('_format', 'api_json') - ->setRequirement('_custom_parameter_names', 'TRUE') + ->setRequirement('_jsonapi_custom_query_parameter_names', 'TRUE') ->setOption('parameters', $parameters) ->setOption('_auth', $this->authProviderList()) ->setOption('serialization_class', EntityReferenceFieldItemList::class) diff --git a/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php b/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php index acfbf8d..6c28a2b 100644 --- a/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php +++ b/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php @@ -3,199 +3,22 @@ namespace Drupal\Tests\jsonapi\Functional; use Drupal\Component\Serialization\Json; -use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Url; -use Drupal\field\Entity\FieldConfig; -use Drupal\field\Entity\FieldStorageConfig; -use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait; -use Drupal\file\Entity\File; use Drupal\jsonapi\Routing\Param\OffsetPage; -use Drupal\taxonomy\Entity\Term; -use Drupal\taxonomy\Entity\Vocabulary; -use Drupal\Tests\BrowserTestBase; -use Drupal\Tests\image\Kernel\ImageFieldCreationTrait; -use Drupal\user\Entity\Role; -use Drupal\user\RoleInterface; -use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\Exception\ServerException; /** * @group jsonapi */ -class JsonApiFunctionalTest extends BrowserTestBase { - - use EntityReferenceTestTrait; - use ImageFieldCreationTrait; - - public static $modules = [ - 'basic_auth', - 'jsonapi', - 'serialization', - 'node', - 'image', - 'taxonomy', - 'link', - ]; - - /** - * @var \Drupal\user\Entity\User - */ - protected $user; - - /** - * @var \Drupal\user\Entity\User - */ - protected $userCanViewProfiles; - - /** - * @var \Drupal\node\Entity\Node[] - */ - protected $nodes = []; - - /** - * @var \Drupal\taxonomy\Entity\Term[] - */ - protected $tags = []; - - /** - * @var \Drupal\file\Entity\File[] - */ - protected $files = []; - - /** - * @var \GuzzleHttp\ClientInterface - */ - protected $httpClient; - - - /** - * {@inheritdoc} - */ - protected function setUp() { - parent::setUp(); - - // Set up a HTTP client that accepts relative URLs. - $this->httpClient = $this->container->get('http_client_factory') - ->fromOptions(['base_uri' => $this->baseUrl]); - - // Create Basic page and Article node types. - if ($this->profile != 'standard') { - $this->drupalCreateContentType(array( - 'type' => 'article', - 'name' => 'Article', - )); - - // Setup vocabulary. - Vocabulary::create([ - 'vid' => 'tags', - 'name' => 'Tags', - ])->save(); - - // Add tags and field_image to the article. - $this->createEntityReferenceField( - 'node', - 'article', - 'field_tags', - 'Tags', - 'taxonomy_term', - 'default', - [ - 'target_bundles' => [ - 'tags' => 'tags', - ], - 'auto_create' => TRUE, - ], - FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED - ); - $this->createImageField('field_image', 'article'); - } - - FieldStorageConfig::create(array( - 'field_name' => 'field_link', - 'entity_type' => 'node', - 'type' => 'link', - 'settings' => [], - 'cardinality' => 1, - ))->save(); - - $field_config = FieldConfig::create([ - 'field_name' => 'field_link', - 'label' => 'Link', - 'entity_type' => 'node', - 'bundle' => 'article', - 'required' => FALSE, - 'settings' => [], - 'description' => '', - ]); - $field_config->save(); - - $this->user = $this->drupalCreateUser([ - 'create article content', - 'edit any article content', - 'delete any article content', - ]); - - // Create a user that can - $this->userCanViewProfiles = $this->drupalCreateUser([ - 'access user profiles', - ]); - - $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [ - 'access user profiles', - 'administer taxonomy', - ]); - - drupal_flush_all_caches(); - } - - /** - * {@inheritdoc} - */ - protected function drupalGet($path, array $options = array(), array $headers = array()) { - // Make sure we don't forget the format parameter. - $options += ['query' => []]; - $options['query'] += ['_format' => 'api_json']; - - return parent::drupalGet($path, $options, $headers); - } - - /** - * Performs a HTTP request. Wraps the Guzzle HTTP client. - * - * Why wrap the Guzzle HTTP client? Because any error response is returned via - * an exception, which would make the tests unnecessarily complex to read. - * - * @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) { - $url->setOption('query', ['_format' => 'api_json']); - try { - $response = $this->httpClient->request($method, $url->toString(), $request_options); - } - catch (ClientException $e) { - $response = $e->getResponse(); - } - catch (ServerException $e) { - $response = $e->getResponse(); - } - - return $response; - } +class JsonApiFunctionalTest extends JsonApiFunctionalTestBase { /** * Test the GET method. */ public function testRead() { - $this->createDefaultContent(60, 5, TRUE, TRUE); + $this->createDefaultContent(61, 5, TRUE, TRUE, static::IS_NOT_MULTILINGUAL); + // Unpublish the last entity, so we can check access. + $this->nodes[60]->setUnpublished()->save(); + // 1. Load all articles (1st page). $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article')); $this->assertSession()->statusCodeEquals(200); @@ -233,6 +56,14 @@ class JsonApiFunctionalTest extends BrowserTestBase { $this->assertSession()->statusCodeEquals(200); $this->assertArrayHasKey('type', $single_output['data']); $this->assertEquals($this->nodes[0]->getTitle(), $single_output['data']['attributes']['title']); + + // 5.1 Single article with access denied. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $this->nodes[60]->uuid())); + $this->assertSession()->statusCodeEquals(403); + + $this->assertEquals('/data', $single_output['errors'][0]['source']['pointer']); + $this->assertEquals('/node--article/' . $this->nodes[60]->uuid(), $single_output['errors'][0]['id']); + // 6. Single relationship item. $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/type')); $this->assertSession()->statusCodeEquals(200); @@ -307,6 +138,8 @@ class JsonApiFunctionalTest extends BrowserTestBase { $this->assertEquals(1, count($single_output['data'])); $this->assertEquals(1, count($single_output['meta']['errors'])); $this->assertEquals(403, $single_output['meta']['errors'][0]['status']); + $this->assertEquals('/node--article/' . $this->nodes[1]->uuid(), $single_output['meta']['errors'][0]['id']); + $this->assertFalse(empty($single_output['meta']['errors'][0]['source']['pointer'])); $this->nodes[1]->set('status', TRUE); $this->nodes[1]->save(); // 13. Test filtering when using short syntax. @@ -417,7 +250,7 @@ class JsonApiFunctionalTest extends BrowserTestBase { * Test POST, PATCH and DELETE. */ public function testWrite() { - $this->createDefaultContent(0, 3, FALSE, FALSE); + $this->createDefaultContent(0, 3, FALSE, FALSE, static::IS_NOT_MULTILINGUAL); // 1. Successful post. $collection_url = Url::fromRoute('jsonapi.node--article.collection'); $body = [ @@ -474,6 +307,7 @@ class JsonApiFunctionalTest extends BrowserTestBase { $this->assertArrayHasKey('uuid', $created_response['data']['attributes']); $uuid = $created_response['data']['attributes']['uuid']; $this->assertEquals(2, count($created_response['data']['relationships']['field_tags']['data'])); + // 2. Authorization error. $response = $this->request('POST', $collection_url, [ 'body' => Json::encode($body), @@ -483,6 +317,18 @@ class JsonApiFunctionalTest extends BrowserTestBase { $this->assertEquals(403, $response->getStatusCode()); $this->assertNotEmpty($created_response['errors']); $this->assertEquals('Forbidden', $created_response['errors'][0]['title']); + + // 2.1 Authorization error with a user without create permissions. + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->userCanViewProfiles->getUsername(), $this->userCanViewProfiles->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(403, $response->getStatusCode()); + $this->assertNotEmpty($created_response['errors']); + $this->assertEquals('Forbidden', $created_response['errors'][0]['title']); + // 3. Missing Content-Type error. $response = $this->request('POST', $collection_url, [ 'body' => Json::encode($body), @@ -545,6 +391,25 @@ class JsonApiFunctionalTest extends BrowserTestBase { $updated_response = Json::decode($response->getBody()->__toString()); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('My updated title', $updated_response['data']['attributes']['title']); + + // 7.1 Unsuccessful PATCH due to access restrictions. + $body = [ + 'data' => [ + 'id' => $uuid, + 'type' => 'node--article', + 'attributes' => ['title' => 'My updated title'], + ], + ]; + $individual_url = Url::fromRoute('jsonapi.node--article.individual', [ + 'node' => $uuid, + ]); + $response = $this->request('PATCH', $individual_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->userCanViewProfiles->getUsername(), $this->userCanViewProfiles->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $this->assertEquals(403, $response->getStatusCode()); + // 8. Field access forbidden check. $body = [ 'data' => [ @@ -564,6 +429,7 @@ class JsonApiFunctionalTest extends BrowserTestBase { $updated_response = Json::decode($response->getBody()->__toString()); $this->assertEquals(403, $response->getStatusCode()); $this->assertEquals('The current user is not allowed to PATCH the selected field (status).', $updated_response['errors'][0]['detail']); + $node = \Drupal::entityManager()->loadEntityByUuid('node', $uuid); $this->assertEquals(1, $node->get('status')->value, 'Node status was not changed.'); // 9. Successful POST to related endpoint. @@ -696,73 +562,4 @@ class JsonApiFunctionalTest extends BrowserTestBase { $this->assertEquals(404, $response->getStatusCode()); } - /** - * Creates default content to test the API. - * - * @param int $num_articles - * Number of articles to create. - * @param int $num_tags - * Number of tags to create. - * @param bool $article_has_image - * Set to TRUE if you want to add an image to the generated articles. - * @param bool $article_has_link - * Set to TRUE if you want to add a link to the generated articles. - */ - protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link) { - $random = $this->getRandomGenerator(); - for ($created_tags = 0; $created_tags < $num_tags; $created_tags++) { - $term = Term::create([ - 'vid' => 'tags', - 'name' => $random->name(), - ]); - $term->save(); - $this->tags[] = $term; - } - for ($created_nodes = 0; $created_nodes < $num_articles; $created_nodes++) { - // Get N random tags. - $selected_tags = mt_rand(1, $num_tags); - $tags = []; - while (count($tags) < $selected_tags) { - $tags[] = mt_rand(1, $num_tags); - $tags = array_unique($tags); - } - $values = [ - 'uid' => ['target_id' => $this->user->id()], - 'type' => 'article', - 'field_tags' => array_map(function ($tag) { - return ['target_id' => $tag]; - }, $tags), - ]; - if ($article_has_image) { - $file = File::create([ - 'uri' => 'vfs://' . $random->name() . '.png', - ]); - $file->setPermanent(); - $file->save(); - $this->files[] = $file; - $values['field_image'] = ['target_id' => $file->id()]; - } - if ($article_has_link) { - $values['field_link'] = [ - 'title' => $this->getRandomGenerator()->name(), - 'uri' => sprintf( - '%s://%s.%s', - 'http' . (mt_rand(0, 2) > 1 ? '' : 's'), - $this->getRandomGenerator()->name(), - 'org' - ), - ]; - } - $this->nodes[] = $this->createNode($values); - } - if ($article_has_link) { - // Make sure that there is at least 1 https link for ::testRead() #19. - $this->nodes[0]->field_link = [ - 'title' => 'Drupal', - 'uri' => 'https://drupal.org' - ]; - $this->nodes[0]->save(); - } - } - } diff --git a/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php b/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php index 93e8609..d37dfd3 100644 --- a/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php +++ b/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php @@ -241,7 +241,8 @@ class EntityResourceTest extends JsonapiKernelTestBase { $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, - $this->container->get('plugin.manager.field.field_type') + $this->container->get('plugin.manager.field.field_type'), + $this->container->get('entity.repository') ); // Get the response. @@ -294,7 +295,8 @@ class EntityResourceTest extends JsonapiKernelTestBase { $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, - $this->container->get('plugin.manager.field.field_type') + $this->container->get('plugin.manager.field.field_type'), + $this->container->get('entity.repository') ); // Get the response. @@ -348,7 +350,8 @@ class EntityResourceTest extends JsonapiKernelTestBase { $this->container->get('jsonapi.query_builder'), $field_manager, $current_context, - $this->container->get('plugin.manager.field.field_type') + $this->container->get('plugin.manager.field.field_type'), + $this->container->get('entity.repository') ); // Get the response. @@ -851,7 +854,8 @@ class EntityResourceTest extends JsonapiKernelTestBase { $this->container->get('jsonapi.query_builder'), $this->container->get('entity_field.manager'), $current_context, - $this->container->get('plugin.manager.field.field_type') + $this->container->get('plugin.manager.field.field_type'), + $this->container->get('entity.repository') ); } diff --git a/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php index 9316bcf..f896ab5 100644 --- a/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php +++ b/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -220,7 +220,7 @@ class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase { ], ], $normalized['data']['relationships']['uid']); $this->assertEquals( - 'Access checks failed for entity user:' . $this->user->id() . '.', + 'The current user is not allowed to GET the selected resource.', $normalized['meta']['errors'][0]['detail'] ); $this->assertEquals(403, $normalized['meta']['errors'][0]['status']); diff --git a/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php b/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php deleted file mode 100644 index 7ba1845..0000000 --- a/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php +++ /dev/null @@ -1,94 +0,0 @@ -attributes->set('_json_api_params', [$name => '123']); - $result = $access_checker->access($request); - - if ($valid) { - $this->assertTrue($result->isAllowed()); - } - else { - $this->assertFalse($result->isAllowed()); - } - } - - public function providerTestJsonApiParamsValidation() { - // Copied from http://jsonapi.org/format/upcoming/#document-member-names. - $data = []; - $data['alphanumeric-lowercase'] = ['12kittens', TRUE]; - $data['alphanumeric-uppercase'] = ['12KITTENS', TRUE]; - $data['alphanumeric-mixed'] = ['12KiTtEnS', TRUE]; - $data['unicode-above-u+0080'] = ['12🐱🐱', TRUE]; - $data['hyphen-start'] = ['-kittens', FALSE]; - $data['hyphen-middle'] = ['kitt-ens', TRUE]; - $data['hyphen-end'] = ['kittens-', FALSE]; - $data['lowline-start'] = ['_kittens', FALSE]; - $data['lowline-middle'] = ['kitt_ens', TRUE]; - $data['lowline-end'] = ['kittens_', FALSE]; - $data['space-start'] = [' kittens', FALSE]; - $data['space-middle'] = ['kitt ens', TRUE]; - $data['space-end'] = ['kittens ', FALSE]; - - $unsafe_chars = [ - '+', - ',', - '.', - '[', - ']', - '!', - '”', - '#', - '$', - '%', - '&', - '’', - '(', - ')', - '*', - '/', - ':', - ';', - '<', - '=', - '>', - '?', - '@', - '\\', - '^', - '`', - '{', - '|', - '}', - '~', - ]; - foreach ($unsafe_chars as $unsafe_char) { - $data['unsafe-' . $unsafe_char] = ['kitt' . $unsafe_char . 'ens', FALSE]; - } - - for ($ascii = 0; $ascii <= 0x1F; $ascii++) { - $data['unsafe-' . $ascii] = ['kitt' . chr($ascii) . 'ens', FALSE]; - } - - return $data; - } - -} diff --git a/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php b/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php index 6731135..04666a6 100644 --- a/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php +++ b/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value; +use Drupal\Component\DependencyInjection\Container; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Url; use Drupal\jsonapi\ResourceType\ResourceType; @@ -31,6 +32,15 @@ class JsonApiDocumentTopLevelNormalizerValueTest extends UnitTestCase { */ protected function setUp() { parent::setUp(); + + $cache_contexts_manager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager') + ->disableOriginalConstructor() + ->getMock(); + $cache_contexts_manager->method('assertValidTokens')->willReturn(TRUE); + $container = new Container(); + $container->set('cache_contexts_manager', $cache_contexts_manager); + \Drupal::setContainer($container); + $field1 = $this->prophesize(FieldNormalizerValueInterface::class); $field1->getIncludes()->willReturn([]); $field1->getPropertyType()->willReturn('attributes'); diff --git a/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php b/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php deleted file mode 100644 index f5a1daf..0000000 --- a/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php +++ /dev/null @@ -1,57 +0,0 @@ -cacheableDependency = new RequestCacheabilityDependency(); - } - - - /** - * @covers ::getCacheContexts - */ - public function testGetCacheContexts() { - $this->assertArrayEquals([ - 'url.query_args:filter', - 'url.query_args:sort', - 'url.query_args:page', - 'url.query_args:fields', - 'url.query_args:include', - ], $this->cacheableDependency->getCacheContexts()); - } - - /** - * @covers ::getCacheContexts - */ - public function testGetCacheTags() { - $this->assertArrayEquals([], $this->cacheableDependency->getCacheTags()); - } - - /** - * @covers ::getCacheContexts - */ - public function testGetCacheMaxAge() { - $this->assertEquals(-1, $this->cacheableDependency->getCacheMaxAge()); - } - -}