diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 910b2ff28..f677f8f64 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,7 +7,7 @@ on: jobs: drupal: name: Drupal ${{ matrix.drupal-core }} (PHP ${{ matrix.php-versions }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: extensions: mbstring, xml, pdo_sqlite, gd, opcache strategy: @@ -19,11 +19,11 @@ jobs: include: # Extra runs to also test on latest Drupal 10. - php-versions: '8.1' - drupal-core: '10.0.x' + drupal-core: '10.1.x' phpstan: '0' # We only need to run PHPStan once on the latest PHP version. - php-versions: '8.2' - drupal-core: '10.0.x' + drupal-core: '10.1.x' phpstan: '1' steps: - name: Checkout Drupal core @@ -60,11 +60,12 @@ jobs: php-version: ${{ matrix.php-versions }} # Disable Xdebug for better performance. coverage: none + ini-file: development extensions: ${{ env.extensions }} - name: Get composer cache directory id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies uses: actions/cache@v3 @@ -82,25 +83,11 @@ jobs: composer config --no-plugins allow-plugins.phpstan/extension-installer true - name: Install GraphQL dependencies - run: composer --no-interaction --no-progress require \ - webonyx/graphql-php:^14.8 \ - drupal/typed_data:^1.0 \ - drupal/redirect:^1.0 \ - phpstan/phpstan:^1.7.14 \ - mglaman/phpstan-drupal:^1.1.2 \ - phpstan/phpstan-deprecation-rules:^1.0.0 \ - jangregor/phpstan-prophecy:^1.0.0 \ - phpstan/phpstan-phpunit:^1.0.0 \ - phpstan/extension-installer:^1.0 - - # We install Coder separately because updating did not work in the local - # Drupal vendor dir. - - name: Install Coder run: | - mkdir coder - cd coder - echo '{"config": {"allow-plugins": {"dealerdirect/phpcodesniffer-composer-installer": true}}}' > composer.json - composer require drupal/coder:8.3.15 --no-interaction --no-progress + composer --no-interaction --no-progress require \ + webonyx/graphql-php:^14.8 \ + drupal/typed_data:^1.0 \ + drupal/redirect:^1.0 - name: Run PHPUnit run: | @@ -109,11 +96,23 @@ jobs: env: SIMPLETEST_DB: "sqlite://localhost/:memory:" + - name: Install PHPStan and Coder dependencies + if: ${{ matrix.phpstan == '1' }} + # Pin the exact Coder version to upgrade manually when we want to. + run: | + composer --no-interaction --no-progress require \ + phpstan/phpstan:^1.10.38 \ + mglaman/phpstan-drupal:^1.1.2 \ + phpstan/phpstan-deprecation-rules:^1.0.0 \ + jangregor/phpstan-prophecy:^1.0.0 \ + phpstan/phpstan-phpunit:^1.0.0 \ + phpstan/extension-installer:^1.0 + composer --no-interaction --no-progress --with-all-dependencies upgrade drupal/coder:8.3.21 + - name: Run PHPStan - # phpstan-drupal bug, so we remove 1 stub file - # https://github.com/mglaman/phpstan-drupal/issues/509 - run: if [[ ${{ matrix.phpstan }} == "1" ]]; then rm vendor/mglaman/phpstan-drupal/stubs/Drupal/Core/Field/FieldItemList.stub && cd modules/graphql && ../../vendor/bin/phpstan analyse; fi + if: ${{ matrix.phpstan == '1' }} + run: cd modules/graphql && ../../vendor/bin/phpstan analyse - name: Run PHPCS - run: | - cd modules/graphql && ../../coder/vendor/bin/phpcs -p + if: ${{ matrix.phpstan == '1' }} + run: cd modules/graphql && ../../vendor/bin/phpcs -p diff --git a/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php b/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php index 8200f5a10..1409c8b40 100644 --- a/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php +++ b/examples/graphql_composable/src/Plugin/GraphQL/DataProducer/CreateArticle.php @@ -4,11 +4,11 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\graphql_composable\GraphQL\Response\ArticleResponse; use Drupal\node\Entity\Node; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; /** * Creates a new article entity. diff --git a/graphql.services.yml b/graphql.services.yml index 39a5eed99..ad0c2c9a7 100644 --- a/graphql.services.yml +++ b/graphql.services.yml @@ -87,7 +87,7 @@ services: # Upcasting for graphql query request parameters. graphql.route_enhancer.query: class: Drupal\graphql\Routing\QueryRouteEnhancer - arguments: ['@request_stack'] + arguments: ['%cors.config%'] tags: - { name: route_enhancer } diff --git a/phpstan.neon b/phpstan.neon index 68be96082..235c373e0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -51,10 +51,3 @@ parameters: message: "#^Method Drupal\\\\graphql\\\\Entity\\\\ServerInterface\\:\\:removePersistedQueryInstance\\(\\) has no return type specified\\.$#" count: 1 path: src/Entity/ServerInterface.php - - # We already use the ProphecyTrait, so not sure why PHPStan complains about - # this? - - """ - #^Call to deprecated method prophesize\\(\\) of class Drupal\\\\Tests\\\\graphql\\\\Kernel\\\\GraphQLTestBase\\: - https\\://github\\.com/sebastianbergmann/phpunit/issues/4141$# - """ diff --git a/src/Entity/Server.php b/src/Entity/Server.php index 39449bb00..78511c4da 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -9,20 +9,20 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\graphql\GraphQL\Execution\ExecutionResult as CacheableExecutionResult; use Drupal\graphql\GraphQL\Execution\FieldContext; -use Drupal\graphql\Plugin\PersistedQueryPluginInterface; -use GraphQL\Error\DebugFlag; -use GraphQL\Server\OperationParams; use Drupal\graphql\GraphQL\Execution\ResolveContext; -use GraphQL\Server\ServerConfig; use Drupal\graphql\GraphQL\ResolverRegistryInterface; use Drupal\graphql\GraphQL\Utility\DeferredUtility; +use Drupal\graphql\Plugin\PersistedQueryPluginInterface; use Drupal\graphql\Plugin\SchemaPluginInterface; +use GraphQL\Error\DebugFlag; use GraphQL\Error\Error; use GraphQL\Error\FormattedError; use GraphQL\Executor\Executor; use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter; use GraphQL\Language\AST\DocumentNode; use GraphQL\Server\Helper; +use GraphQL\Server\OperationParams; +use GraphQL\Server\ServerConfig; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Validator\DocumentValidator; use GraphQL\Validator\Rules\DisableIntrospection; diff --git a/src/Event/OperationEvent.php b/src/Event/OperationEvent.php index f51164b21..dd0326809 100644 --- a/src/Event/OperationEvent.php +++ b/src/Event/OperationEvent.php @@ -2,9 +2,9 @@ namespace Drupal\graphql\Event; +use Drupal\Component\EventDispatcher\Event; use Drupal\graphql\GraphQL\Execution\ResolveContext; use GraphQL\Executor\ExecutionResult; -use Drupal\Component\EventDispatcher\Event; /** * Represents an event that is triggered before and after a GraphQL operation. diff --git a/src/EventSubscriber/ApqSubscriber.php b/src/EventSubscriber/ApqSubscriber.php index 1dd3ca5a5..3594fd49b 100644 --- a/src/EventSubscriber/ApqSubscriber.php +++ b/src/EventSubscriber/ApqSubscriber.php @@ -4,8 +4,8 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\graphql\Event\OperationEvent; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; use GraphQL\Error\Error; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** * Save persisted queries to cache. diff --git a/src/Form/ServerForm.php b/src/Form/ServerForm.php index c1b747e37..eb955f315 100644 --- a/src/Form/ServerForm.php +++ b/src/Form/ServerForm.php @@ -7,6 +7,7 @@ use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\SubformState; @@ -243,6 +244,19 @@ public function form(array $form, FormStateInterface $formState): array { return $form; } + /** + * {@inheritdoc} + */ + protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state): void { + // Translate the debug flag from individual checkboxes to the enum value + // that the GraphQL library expects. + $debug_flag = $form_state->getValue('debug_flag'); + if (is_array($debug_flag)) { + $form_state->setValue('debug_flag', array_sum($debug_flag)); + } + parent::copyFormValuesToEntity($entity, $form, $form_state); + } + /** * {@inheritdoc} * @@ -274,9 +288,6 @@ public function validateForm(array &$form, FormStateInterface $formState): void * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $formState): void { - // Translate the debug flag from individual checkboxes to the enum value - // that the GraphQL library expects. - $formState->setValue('debug_flag', array_sum($formState->getValue('debug_flag'))); parent::submitForm($form, $formState); $schema = $formState->getValue('schema'); diff --git a/src/GraphQL/Execution/ExecutionResult.php b/src/GraphQL/Execution/ExecutionResult.php index 127f04b01..113333d1e 100644 --- a/src/GraphQL/Execution/ExecutionResult.php +++ b/src/GraphQL/Execution/ExecutionResult.php @@ -2,9 +2,9 @@ namespace Drupal\graphql\GraphQL\Execution; -use GraphQL\Executor\ExecutionResult as LibraryExecutionResult; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\RefinableCacheableDependencyTrait; +use GraphQL\Executor\ExecutionResult as LibraryExecutionResult; /** * Expand the upstream ExecutionResult to make it Drupal cachable. diff --git a/src/GraphQL/Execution/Executor.php b/src/GraphQL/Execution/Executor.php index 0d0e92edd..b8ed3a6cb 100644 --- a/src/GraphQL/Execution/Executor.php +++ b/src/GraphQL/Execution/Executor.php @@ -356,6 +356,7 @@ protected function cachePrefix() { 'query' => DocumentSerializer::serializeDocument($this->document), 'variables' => $variables, 'extensions' => $extensions, + 'operation' => $this->operation, ])); return $hash; diff --git a/src/GraphQL/Resolver/Composite.php b/src/GraphQL/Resolver/Composite.php index d72725812..fa9c1ac23 100644 --- a/src/GraphQL/Resolver/Composite.php +++ b/src/GraphQL/Resolver/Composite.php @@ -3,9 +3,9 @@ namespace Drupal\graphql\GraphQL\Resolver; use Drupal\graphql\GraphQL\Execution\FieldContext; -use GraphQL\Executor\Promise\Adapter\SyncPromise; -use Drupal\graphql\GraphQL\Utility\DeferredUtility; use Drupal\graphql\GraphQL\Execution\ResolveContext; +use Drupal\graphql\GraphQL\Utility\DeferredUtility; +use GraphQL\Executor\Promise\Adapter\SyncPromise; use GraphQL\Type\Definition\ResolveInfo; /** diff --git a/src/GraphQL/Resolver/Condition.php b/src/GraphQL/Resolver/Condition.php index b86984f1a..4eb95b5ee 100644 --- a/src/GraphQL/Resolver/Condition.php +++ b/src/GraphQL/Resolver/Condition.php @@ -37,7 +37,8 @@ public function __construct(array $branches) { */ public function resolve($value, $args, ResolveContext $context, ResolveInfo $info, FieldContext $field) { $branches = $this->branches; - while ([$condition, $resolver] = array_pad(array_shift($branches), 2, NULL)) { + while ($branch = array_shift($branches)) { + [$condition, $resolver] = array_pad($branch, 2, NULL); if ($condition instanceof ResolverInterface) { if (($condition = $condition->resolve($value, $args, $context, $info, $field)) === NULL) { // Bail out early if a resolver returns NULL. diff --git a/src/GraphQL/ResolverBuilder.php b/src/GraphQL/ResolverBuilder.php index 19b6722d9..6ebfdaf27 100644 --- a/src/GraphQL/ResolverBuilder.php +++ b/src/GraphQL/ResolverBuilder.php @@ -11,12 +11,12 @@ use Drupal\graphql\GraphQL\Resolver\DefaultValue; use Drupal\graphql\GraphQL\Resolver\Map; use Drupal\graphql\GraphQL\Resolver\ParentValue; +use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use Drupal\graphql\GraphQL\Resolver\SourceContext; use Drupal\graphql\GraphQL\Resolver\Tap; use Drupal\graphql\GraphQL\Resolver\Value; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerProxy; use Drupal\typed_data\DataFetcherTrait; -use Drupal\graphql\GraphQL\Resolver\ResolverInterface; /** * Wires and maps different resolvers together to build the GraphQL tree. diff --git a/src/GraphQL/ResolverRegistry.php b/src/GraphQL/ResolverRegistry.php index 30181e5b5..62c941476 100644 --- a/src/GraphQL/ResolverRegistry.php +++ b/src/GraphQL/ResolverRegistry.php @@ -4,11 +4,11 @@ use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\GraphQL\Execution\ResolveContext; +use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use GraphQL\Executor\Executor; use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; -use Drupal\graphql\GraphQL\Resolver\ResolverInterface; /** * Contains all the mappings how to resolve a GraphQL request. diff --git a/src/GraphQL/Utility/FileUpload.php b/src/GraphQL/Utility/FileUpload.php index bd22f7e0a..0422f2350 100644 --- a/src/GraphQL/Utility/FileUpload.php +++ b/src/GraphQL/Utility/FileUpload.php @@ -8,6 +8,7 @@ use Drupal\Component\Utility\Environment; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\File\Event\FileUploadSanitizeNameEvent; use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Image\ImageFactory; @@ -20,10 +21,9 @@ use Drupal\Core\Utility\Token; use Drupal\file\FileInterface; use Drupal\graphql\GraphQL\Response\FileUploadResponse; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; -use Drupal\Core\File\Event\FileUploadSanitizeNameEvent; use Symfony\Component\Mime\MimeTypeGuesserInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Service to manage file uploads within GraphQL mutations. diff --git a/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php b/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php index ac510dcf6..8ed6a429b 100644 --- a/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php +++ b/src/Plugin/GraphQL/DataProducer/DataProducerPluginBase.php @@ -5,11 +5,11 @@ use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\PluginBase; use Drupal\Core\DependencyInjection\DependencySerializationTrait; -use Drupal\graphql\GraphQL\Execution\FieldContext; -use Drupal\graphql\Plugin\DataProducerPluginInterface; use Drupal\Core\Plugin\ContextAwarePluginTrait; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\TypedData\TypedDataTrait; +use Drupal\graphql\GraphQL\Execution\FieldContext; +use Drupal\graphql\Plugin\DataProducerPluginInterface; /** * Base class for data producers that resolve fields for queries or mutations. diff --git a/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php b/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php index 4e5747099..faf933aac 100644 --- a/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php +++ b/src/Plugin/GraphQL/DataProducer/DataProducerProxy.php @@ -14,8 +14,8 @@ use Drupal\graphql\Plugin\DataProducerPluginCachingInterface; use Drupal\graphql\Plugin\DataProducerPluginInterface; use Drupal\graphql\Plugin\DataProducerPluginManager; -use Symfony\Component\HttpFoundation\RequestStack; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpFoundation\RequestStack; /** * A proxy class that lazy resolves data producers and has a result cache. diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php index c672fa109..a641101bf 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityAccess.php @@ -2,8 +2,9 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity; -use Drupal\Core\Session\AccountInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; /** @@ -37,13 +38,21 @@ class EntityAccess extends DataProducerPluginBase { * Resolver. * * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check access for. * @param string $operation + * The access operation, for example "view". * @param \Drupal\Core\Session\AccountInterface $user + * The user account access should be checked for. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The context to add caching information to. * - * @return bool|\Drupal\Core\Access\AccessResultInterface + * @return bool + * TRUE when access to the entity is allowed, FALSE otherwise. */ - public function resolve(EntityInterface $entity, $operation = NULL, AccountInterface $user = NULL) { - return $entity->access($operation ?? 'view', $user); + public function resolve(EntityInterface $entity, ?string $operation, ?AccountInterface $user, FieldContext $context) { + $result = $entity->access($operation ?? 'view', $user, TRUE); + $context->addCacheableDependency($result); + return $result->isAllowed(); } } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php index 7e9ec9272..7b6726359 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityLabel.php @@ -3,6 +3,8 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\DataProducerPluginCachingInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; @@ -19,7 +21,12 @@ * consumes = { * "entity" = @ContextDefinition("entity", * label = @Translation("Entity") - * ) + * ), + * "access_user" = @ContextDefinition("entity:user", + * label = @Translation("User"), + * required = FALSE, + * default_value = NULL + * ), * } * ) */ @@ -29,11 +36,19 @@ class EntityLabel extends DataProducerPluginBase implements DataProducerPluginCa * Resolver. * * @param \Drupal\Core\Entity\EntityInterface $entity + * @param \Drupal\Core\Session\AccountInterface|null $accessUser + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return string|null */ - public function resolve(EntityInterface $entity) { - return $entity->label(); + public function resolve(EntityInterface $entity, ?AccountInterface $accessUser, FieldContext $context) { + /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ + $accessResult = $entity->access('view label', $accessUser, TRUE); + $context->addCacheableDependency($accessResult); + if ($accessResult->isAllowed()) { + return $entity->label(); + } + return NULL; } } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php new file mode 100644 index 000000000..e24412acb --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQuery.php @@ -0,0 +1,195 @@ + 'created', + * 'direction' => 'DESC', + * ], + * ]; + * $registry->addFieldResolver('Query', 'jobApplicationsByUserId', + * $builder->compose( + * $builder->fromArgument('id'), + * $builder->callback(function ($uid) { + * $conditions = [ + * [ + * 'field' => 'uid', + * 'value' => [$uid], + * ], + * ]; + * return $conditions; + * }), + * $builder->produce('entity_query', [ + * 'type' => $builder->fromValue('node'), + * 'conditions' => $builder->fromParent(), + * 'offset' => $builder->fromArgument('offset'), + * 'limit' => $builder->fromArgument('limit'), + * 'language' => $builder->fromArgument('language'), + * 'allowed_filters' => $builder->fromValue(['uid']), + * 'bundles' => $builder->fromValue(['job_application']), + * 'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting), + * ]), + * $builder->produce('entity_load_multiple', [ + * 'type' => $builder->fromValue('node'), + * 'ids' => $builder->fromParent(), + * ]), + * ) + * ); + * @endcode + * + * @DataProducer( + * id = "entity_query", + * name = @Translation("Load entities"), + * description = @Translation("Returns entity IDs for a given query"), + * produces = @ContextDefinition("string", + * label = @Translation("Entity IDs"), + * multiple = TRUE + * ), + * consumes = { + * "type" = @ContextDefinition("string", + * label = @Translation("Entity type") + * ), + * "limit" = @ContextDefinition("integer", + * label = @Translation("Limit"), + * required = FALSE, + * default_value = 10 + * ), + * "offset" = @ContextDefinition("integer", + * label = @Translation("Offset"), + * required = FALSE, + * default_value = 0 + * ), + * "owned_only" = @ContextDefinition("boolean", + * label = @Translation("Query only owned entities"), + * required = FALSE, + * default_value = FALSE + * ), + * "conditions" = @ContextDefinition("any", + * label = @Translation("Conditions"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "allowed_filters" = @ContextDefinition("string", + * label = @Translation("Allowed filters"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "languages" = @ContextDefinition("string", + * label = @Translation("Entity languages"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "bundles" = @ContextDefinition("any", + * label = @Translation("Entity bundles"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "access" = @ContextDefinition("boolean", + * label = @Translation("Check access"), + * required = FALSE, + * default_value = TRUE + * ), + * "sorts" = @ContextDefinition("any", + * label = @Translation("Sorts"), + * multiple = TRUE, + * default_value = {}, + * required = FALSE + * ) + * } + * ) + */ +class EntityQuery extends EntityQueryBase { + + /** + * The default maximum number of items to be capped to prevent DDOS attacks. + */ + const MAX_ITEMS = 100; + + /** + * Resolves the entity query. + * + * @param string $type + * Entity type. + * @param int $limit + * Maximum number of queried entities. + * @param int $offset + * Offset to start with. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param array $sorts + * List of sorts. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return array + * The list of ids that match this query. + * + * @throws \GraphQL\Error\UserError + * No bundles defined for given entity type. + */ + public function resolve(string $type, int $limit, int $offset, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, array $sorts, FieldContext $context): array { + $query = $this->buildBaseEntityQuery( + $type, + $ownedOnly, + $conditions, + $allowedFilters, + $languages, + $bundles, + $access, + $context + ); + + // Make sure offset is zero or positive. + $offset = max($offset, 0); + + // Make sure limit is positive and cap the max items to prevent DDOS + // attacks. + if ($limit <= 0) { + $limit = 10; + } + $limit = min($limit, self::MAX_ITEMS); + + // Apply offset and limit. + $query->range($offset, $limit); + + // Add sorts. + foreach ($sorts as $sort) { + if (!empty($sort['field'])) { + if (!empty($sort['direction']) && strtolower($sort['direction']) == 'desc') { + $direction = 'DESC'; + } + else { + $direction = 'ASC'; + } + $query->sort($sort['field'], $direction); + } + } + + $ids = $query->execute(); + + return $ids; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php new file mode 100644 index 000000000..4d0550062 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryBase.php @@ -0,0 +1,141 @@ +get('entity_type.manager'), + $container->get('current_user') + ); + } + + /** + * EntityLoad constructor. + * + * @param array $configuration + * The plugin configuration array. + * @param string $pluginId + * The plugin id. + * @param array $pluginDefinition + * The plugin definition array. + * @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager + * The entity type manager service. + * @param \Drupal\Core\Session\AccountProxyInterface $current_user + * The current user proxy. + */ + public function __construct( + array $configuration, + string $pluginId, + array $pluginDefinition, + EntityTypeManager $entityTypeManager, + AccountProxyInterface $current_user + ) { + parent::__construct($configuration, $pluginId, $pluginDefinition); + $this->entityTypeManager = $entityTypeManager; + $this->currentUser = $current_user; + } + + /** + * Build base entity query which may be reused for count query as well. + * + * @param string $type + * Entity type. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * Base entity query. + * + * @throws \GraphQL\Error\UserError + * No bundles defined for given entity type. + */ + protected function buildBaseEntityQuery(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): QueryInterface { + $entity_type = $this->entityTypeManager->getStorage($type); + $query = $entity_type->getQuery(); + + // Query only those entities which are owned by current user, if desired. + if ($ownedOnly) { + $query->condition('uid', $this->currentUser->id()); + // Add user cacheable dependencies. + $account = $this->currentUser->getAccount(); + $context->addCacheableDependency($account); + // Cache response per user to make sure the user related result is shown. + $context->addCacheContexts(['user']); + } + + // Ensure that desired access checking is performed on the query. + $query->accessCheck($access); + + // Filter entities only of given bundles, if desired. + if ($bundles) { + $bundle_key = $entity_type->getEntityType()->getKey('bundle'); + if (!$bundle_key) { + throw new UserError('No bundles defined for given entity type.'); + } + $query->condition($bundle_key, $bundles, 'IN'); + } + + // Filter entities by given languages, if desired. + if ($languages) { + $query->condition('langcode', $languages, 'IN'); + } + + // Filter by given conditions. + foreach ($conditions as $condition) { + if (!in_array($condition['field'], $allowedFilters)) { + throw new UserError("Field '{$condition['field']}' is not allowed as filter."); + } + $operation = $condition['operator'] ?? NULL; + $query->condition($condition['field'], $condition['value'], $operation); + } + + return $query; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php new file mode 100644 index 000000000..c7f783b1e --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityQueryCount.php @@ -0,0 +1,138 @@ + 'created', + * 'direction' => 'DESC', + * ], + * ]; + * $registry->addFieldResolver('Query', 'jobApplicationsByUserIdCount', + * $builder->compose( + * $builder->fromArgument('id'), + * $builder->callback(function ($uid) { + * $conditions = [ + * [ + * 'field' => 'uid', + * 'value' => [$uid], + * ], + * ]; + * return $conditions; + * }), + * $builder->produce('entity_query_count', [ + * 'type' => $builder->fromValue('node'), + * 'conditions' => $builder->fromParent(), + * 'offset' => $builder->fromArgument('offset'), + * 'limit' => $builder->fromArgument('limit'), + * 'language' => $builder->fromArgument('language'), + * 'allowed_filters' => $builder->fromValue(['uid']), + * 'bundles' => $builder->fromValue(['job_application']), + * 'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting), + * ]) + * ) + * ); + * @endcode + * + * @DataProducer( + * id = "entity_query_count", + * name = @Translation("Load entities"), + * description = @Translation("Loads entities."), + * produces = @ContextDefinition("integer", + * label = @Translation("Total count of items queried by entity query."), + * ), + * consumes = { + * "type" = @ContextDefinition("string", + * label = @Translation("Entity type") + * ), + * "owned_only" = @ContextDefinition("boolean", + * label = @Translation("Query only owned entities"), + * required = FALSE, + * default_value = FALSE + * ), + * "conditions" = @ContextDefinition("any", + * label = @Translation("Conditions"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "allowed_filters" = @ContextDefinition("string", + * label = @Translation("Allowed filters"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "languages" = @ContextDefinition("string", + * label = @Translation("Entity languages"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "bundles" = @ContextDefinition("any", + * label = @Translation("Entity bundles"), + * multiple = TRUE, + * required = FALSE, + * default_value = {} + * ), + * "access" = @ContextDefinition("boolean", + * label = @Translation("Check access"), + * required = FALSE, + * default_value = TRUE + * ) + * } + * ) + */ +class EntityQueryCount extends EntityQueryBase { + + /** + * Resolves the entity query count. + * + * @param string $type + * Entity type. + * @param bool $ownedOnly + * Query only entities owned by current user. + * @param array $conditions + * List of conditions to filter the entities. + * @param array $allowedFilters + * List of fields to be used in conditions to restrict access to data. + * @param string[] $languages + * Languages for queried entities. + * @param string[] $bundles + * List of bundles to be filtered. + * @param bool $access + * Whether entity query should check access. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * The caching context related to the current field. + * + * @return int + * Total count of items queried by entity query. + */ + public function resolve(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): int { + $query = $this->buildBaseEntityQuery( + $type, + $ownedOnly, + $conditions, + $allowedFilters, + $languages, + $bundles, + $access, + $context + ); + + return $query->count()->execute(); + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php index 633bdc29d..1a30c65dc 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslation.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\TranslatableInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -97,10 +98,11 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, * @param bool|null $access * @param \Drupal\Core\Session\AccountInterface|null $accessUser * @param string|null $accessOperation + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return \Drupal\Core\Entity\EntityInterface|null */ - public function resolve(EntityInterface $entity, $language, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation) { + public function resolve(EntityInterface $entity, $language, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation, FieldContext $context) { if ($entity instanceof TranslatableInterface && $entity->isTranslatable()) { $entity = $entity->getTranslation($language); $entity->addCacheContexts(["static:language:{$language}"]); @@ -109,6 +111,7 @@ public function resolve(EntityInterface $entity, $language, ?bool $access, ?Acco if ($access) { /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ $accessResult = $entity->access($accessOperation, $accessUser, TRUE); + $context->addCacheableDependency($accessResult); if (!$accessResult->isAllowed()) { return NULL; } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php index 5a68d8e89..c78ac6b4b 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/EntityTranslations.php @@ -9,6 +9,7 @@ use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -96,20 +97,22 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, * @param bool|null $access * @param \Drupal\Core\Session\AccountInterface|null $accessUser * @param string|null $accessOperation + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context * * @return array|null */ - public function resolve(EntityInterface $entity, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation) { + public function resolve(EntityInterface $entity, ?bool $access, ?AccountInterface $accessUser, ?string $accessOperation, FieldContext $context) { if ($entity instanceof TranslatableInterface && $entity->isTranslatable()) { $languages = $entity->getTranslationLanguages(); - return array_map(function (LanguageInterface $language) use ($entity, $access, $accessOperation, $accessUser) { + return array_map(function (LanguageInterface $language) use ($entity, $access, $accessOperation, $accessUser, $context) { $langcode = $language->getId(); $entity = $entity->getTranslation($langcode); $entity->addCacheContexts(["static:language:{$langcode}"]); if ($access) { /** @var \Drupal\Core\Access\AccessResultInterface $accessResult */ $accessResult = $entity->access($accessOperation, $accessUser, TRUE); + $context->addCacheableDependency($accessResult); if (!$accessResult->isAllowed()) { return NULL; } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php index 04a16bf8c..d2d7449de 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageDerivative.php @@ -89,7 +89,7 @@ public function __construct( * @return array|null */ public function resolve(FileInterface $entity = NULL, $style, RefinableCacheableDependencyInterface $metadata) { - // Return if we dont have an entity. + // Return if we don't have an entity. if (!$entity) { return NULL; } diff --git a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php index 84b1cfbc2..001cb0aec 100644 --- a/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php +++ b/src/Plugin/GraphQL/DataProducer/Entity/Fields/Image/ImageUrl.php @@ -3,13 +3,13 @@ namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity\Fields\Image; use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\File\FileUrlGeneratorInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\file\FileInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; -use Drupal\Core\File\FileUrlGeneratorInterface; /** * Returns the file URL of a file entity. diff --git a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php index c09266dbc..8439eb20d 100644 --- a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php +++ b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceLayoutRevisions.php @@ -8,9 +8,9 @@ use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; -use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use GraphQL\Deferred; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php index b0f9a509a..0a2503a21 100644 --- a/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php +++ b/src/Plugin/GraphQL/DataProducer/Field/EntityReferenceRevisions.php @@ -8,9 +8,9 @@ use Drupal\Core\Field\EntityReferenceFieldItemListInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; -use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; use GraphQL\Deferred; use Symfony\Component\DependencyInjection\ContainerInterface; diff --git a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php index 3e8d92df5..0f50a9f12 100644 --- a/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/AlterableComposableSchema.php @@ -2,14 +2,14 @@ namespace Drupal\graphql\Plugin\GraphQL\Schema; +use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher; use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\graphql\Event\AlterSchemaDataEvent; use Drupal\graphql\Event\AlterSchemaExtensionDataEvent; use Drupal\graphql\Plugin\SchemaExtensionPluginInterface; -use GraphQL\Language\Parser; -use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\graphql\Plugin\SchemaExtensionPluginManager; +use GraphQL\Language\Parser; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -130,7 +130,10 @@ protected function getSchemaDocument(array $extensions = []) { $event, AlterSchemaDataEvent::EVENT_NAME ); - $ast = Parser::parse(implode("\n\n", $event->getSchemaData())); + // For caching and parsing big schemas we need to disable the creation of + // location nodes in the AST object to prevent serialization and memory + // errors. See https://github.com/webonyx/graphql-php/issues/1164 + $ast = Parser::parse(implode("\n\n", $event->getSchemaData()), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } @@ -172,7 +175,7 @@ protected function getExtensionDocument(array $extensions = []) { $event, AlterSchemaExtensionDataEvent::EVENT_NAME ); - $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $event->getSchemaExtensionData())) : NULL; + $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $event->getSchemaExtensionData()), ['noLocation' => TRUE]) : NULL; if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } diff --git a/src/Plugin/GraphQL/Schema/ComposableSchema.php b/src/Plugin/GraphQL/Schema/ComposableSchema.php index eeb439e47..ddb820f75 100644 --- a/src/Plugin/GraphQL/Schema/ComposableSchema.php +++ b/src/Plugin/GraphQL/Schema/ComposableSchema.php @@ -6,8 +6,8 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\PluginFormInterface; -use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\graphql\GraphQL\ResolverRegistry; /** * @Schema( diff --git a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php index 47d1e7f2c..cae50d5d0 100644 --- a/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php +++ b/src/Plugin/GraphQL/Schema/SdlSchemaPluginBase.php @@ -174,7 +174,10 @@ protected function getSchemaDocument(array $extensions = []) { }); $schema = array_merge([$this->getSchemaDefinition()], $extensions); - $ast = Parser::parse(implode("\n\n", $schema)); + // For caching and parsing big schemas we need to disable the creation of + // location nodes in the AST object to prevent serialization and memory + // errors. See https://github.com/webonyx/graphql-php/issues/1164 + $ast = Parser::parse(implode("\n\n", $schema), ['noLocation' => TRUE]); if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } @@ -205,7 +208,7 @@ protected function getExtensionDocument(array $extensions = []) { return !empty($definition); }); - $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $extensions)) : NULL; + $ast = !empty($extensions) ? Parser::parse(implode("\n\n", $extensions), ['noLocation' => TRUE]) : NULL; if (empty($this->inDevelopment)) { $this->astCache->set($cid, $ast, CacheBackendInterface::CACHE_PERMANENT, ['graphql']); } diff --git a/src/Plugin/SchemaExtensionPluginInterface.php b/src/Plugin/SchemaExtensionPluginInterface.php index 234aa19da..75eca1fcd 100644 --- a/src/Plugin/SchemaExtensionPluginInterface.php +++ b/src/Plugin/SchemaExtensionPluginInterface.php @@ -2,8 +2,8 @@ namespace Drupal\graphql\Plugin; -use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Component\Plugin\DerivativeInspectionInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\graphql\GraphQL\ResolverRegistryInterface; /** diff --git a/src/Plugin/SchemaPluginInterface.php b/src/Plugin/SchemaPluginInterface.php index d13c1e71d..ab8dbc33a 100644 --- a/src/Plugin/SchemaPluginInterface.php +++ b/src/Plugin/SchemaPluginInterface.php @@ -2,8 +2,8 @@ namespace Drupal\graphql\Plugin; -use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\Component\Plugin\DerivativeInspectionInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; use Drupal\graphql\GraphQL\ResolverRegistryInterface; /** diff --git a/src/Routing/QueryRouteEnhancer.php b/src/Routing/QueryRouteEnhancer.php index f2508edb3..ca2daa5df 100644 --- a/src/Routing/QueryRouteEnhancer.php +++ b/src/Routing/QueryRouteEnhancer.php @@ -2,12 +2,14 @@ namespace Drupal\graphql\Routing; +use Asm89\Stack\CorsService; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Routing\EnhancerInterface; use Drupal\Core\Routing\RouteObjectInterface; use Drupal\graphql\GraphQL\Utility\JsonHelper; use GraphQL\Server\Helper; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Route; /** @@ -15,6 +17,20 @@ */ class QueryRouteEnhancer implements EnhancerInterface { + /** + * The CORS options for Origin header checking. + * + * @var array + */ + protected $corsOptions; + + /** + * Constructor. + */ + public function __construct(array $corsOptions) { + $this->corsOptions = $corsOptions; + } + /** * Returns whether the enhancer runs on the current route. * @@ -38,6 +54,10 @@ public function enhance(array $defaults, Request $request) { return $defaults; } + if ($request->getMethod() === "POST") { + $this->assertValidPostRequestHeaders($request); + } + $helper = new Helper(); $method = $request->getMethod(); $body = $this->extractBody($request); @@ -47,6 +67,87 @@ public function enhance(array $defaults, Request $request) { return $defaults + ['operations' => $operations]; } + /** + * Ensures that the headers for a POST request have triggered a preflight. + * + * POST requests must be submitted with content-type headers that properly + * trigger a cross-origin preflight request. In case content-headers are used + * that would trigger a "simple" request then custom headers must be provided. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request to check. + * + * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * In case the headers indicated a preflight was not performed. + */ + protected function assertValidPostRequestHeaders(Request $request) : void { + $content_type = $request->headers->get('content-type'); + if ($content_type === NULL) { + throw new BadRequestHttpException("GraphQL requests must specify a valid content type header."); + } + + // application/graphql is a non-standard header that's supported by our + // server implementation and triggers CORS. + if ($content_type === "application/graphql") { + return; + } + + // @phpstan-ignore-next-line + $content_format = method_exists($request, 'getContentTypeFormat') ? $request->getContentTypeFormat() : $request->getContentType(); + if ($content_format === NULL) { + // Symfony before 5.4 does not detect "multipart/form-data", check for it + // manually. + if (stripos($content_type, 'multipart/form-data') === 0) { + $content_format = 'form'; + } + else { + throw new BadRequestHttpException("The content type '$content_type' is not supported."); + } + } + + // JSON requests provide a non-standard header that trigger CORS. + if ($content_format === "json") { + return; + } + + // The form content types are considered simple requests and don't trigger + // CORS pre-flight checks, so these require a separate header to prevent + // CSRF. We need to support "form" for file uploads. + if ($content_format === "form") { + // If the client set a custom header then we can be sure CORS was + // respected. + $custom_headers = [ + 'Apollo-Require-Preflight', + 'X-Apollo-Operation-Name', + 'x-graphql-yoga-csrf', + ]; + foreach ($custom_headers as $custom_header) { + if ($request->headers->has($custom_header)) { + return; + } + } + // 1. Allow requests that have set no Origin header at all, for example + // server-to-server requests. + // 2. Allow requests where the Origin matches the site's domain name. + $origin = $request->headers->get('Origin'); + if ($origin === NULL || $request->getSchemeAndHttpHost() === $origin) { + return; + } + // Allow other origins as configured in the CORS policy. + if (!empty($this->corsOptions['enabled'])) { + $cors_service = new CorsService($this->corsOptions); + // Drupal 9 compatibility, method name has changed in Drupal 10. + // @phpstan-ignore-next-line + if ($cors_service->isActualRequestAllowed($request)) { + return; + } + } + throw new BadRequestHttpException("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings."); + } + + throw new BadRequestHttpException("The content type '$content_type' is not supported."); + } + /** * Extracts the query parameters from a request. * diff --git a/tests/modules/graphql_file_validate/graphql_file_validate.module b/tests/modules/graphql_file_validate/graphql_file_validate.module index 9e1db9dd9..ae89684be 100644 --- a/tests/modules/graphql_file_validate/graphql_file_validate.module +++ b/tests/modules/graphql_file_validate/graphql_file_validate.module @@ -10,7 +10,7 @@ use Drupal\file\FileInterface; /** * Implements hook_file_validate(). */ -function graphql_file_validate(FileInterface $file): void { +function graphql_file_validate_file_validate(FileInterface $file): void { if (!file_exists($file->getFileUri())) { throw new \Exception('File does not exist during validation: ' . $file->getFileUri()); } diff --git a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php index cfd9263d7..18ef12b7c 100644 --- a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php +++ b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageDerivativeTest.php @@ -20,22 +20,53 @@ class ImageDerivativeTest extends GraphQLTestBase { */ protected static $modules = ['image', 'file']; + /** + * The file system URI under test. + * + * @var string + */ + protected $fileUri; + + /** + * The file entity mock. + * + * @var \Drupal\file\FileInterface + */ + protected $file; + + /** + * The image style for testing. + * + * @var \Drupal\image\Entity\ImageStyle + */ + protected $style; + + /** + * A file entity mock that returns FALSE on access checking. + * + * @var \Drupal\file\FileInterface + */ + protected $fileNotAccessible; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->file_uri = 'public://test.jpg'; + $this->fileUri = 'public://test.jpg'; $this->file = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file->method('getFileUri')->willReturn($this->file_uri); + $this->file->method('getFileUri')->willReturn($this->fileUri); $this->file->method('access')->willReturn((new AccessResultAllowed())->addCacheTags(['test_tag'])); - $this->file->width = 600; - $this->file->height = 400; + // @todo Remove hard-coded properties and only rely on image factory. + // @phpstan-ignore-next-line + @$this->file->width = 600; + // @phpstan-ignore-next-line + @$this->file->height = 400; $this->style = ImageStyle::create(['name' => 'test_style']); $effect = [ @@ -49,11 +80,11 @@ public function setUp(): void { $this->style->addImageEffect($effect); $this->style->save(); - $this->file_not_accessible = $this->getMockBuilder(FileInterface::class) + $this->fileNotAccessible = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file_not_accessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); + $this->fileNotAccessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); } /** @@ -69,7 +100,7 @@ public function testImageDerivative(): void { $this->assertEquals( [ - 'url' => $this->style->buildUrl($this->file_uri), + 'url' => $this->style->buildUrl($this->fileUri), 'width' => 300, 'height' => 200, ], @@ -83,7 +114,7 @@ public function testImageDerivative(): void { // Test that we don't get the derivative if we don't have access to the // original file, but we still get the access result cache tags. $result = $this->executeDataProducer('image_derivative', [ - 'entity' => $this->file_not_accessible, + 'entity' => $this->fileNotAccessible, 'style' => 'test_style', ]); diff --git a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php index 705da4363..24e10ba60 100644 --- a/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php +++ b/tests/src/Kernel/DataProducer/Entity/Fields/Image/ImageUrlTest.php @@ -14,12 +14,32 @@ */ class ImageUrlTest extends GraphQLTestBase { + /** + * The file entity mock. + * + * @var \Drupal\file\FileInterface + */ + protected $file; + + /** + * A file entity mock that returns FALSE on access checking. + * + * @var \Drupal\file\FileInterface + */ + protected $fileNotAccessible; + + /** + * The generated file URI. + * + * @var string + */ + protected $fileUri; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->dataProducerManager = $this->container->get('plugin.manager.graphql.data_producer'); $this->fileUri = \Drupal::service('file_url_generator')->generateAbsoluteString('public://test.jpg'); @@ -30,11 +50,11 @@ public function setUp(): void { $this->file->method('getFileUri')->willReturn($this->fileUri); $this->file->method('access')->willReturn((new AccessResultAllowed())->addCacheTags(['test_tag'])); - $this->file_not_accessible = $this->getMockBuilder(FileInterface::class) + $this->fileNotAccessible = $this->getMockBuilder(FileInterface::class) ->disableOriginalConstructor() ->getMock(); - $this->file_not_accessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); + $this->fileNotAccessible->method('access')->willReturn((new AccessResultForbidden())->addCacheTags(['test_tag_forbidden'])); } /** @@ -53,7 +73,7 @@ public function testImageUrl(): void { // Test that we do not get a file we don't have access to, but the cache // tags are still added. $result = $this->executeDataProducer('image_url', [ - 'entity' => $this->file_not_accessible, + 'entity' => $this->fileNotAccessible, ]); $this->assertNull($result); diff --git a/tests/src/Kernel/DataProducer/EntityDefinitionTest.php b/tests/src/Kernel/DataProducer/EntityDefinitionTest.php index beaa951cf..9dc325989 100644 --- a/tests/src/Kernel/DataProducer/EntityDefinitionTest.php +++ b/tests/src/Kernel/DataProducer/EntityDefinitionTest.php @@ -6,9 +6,9 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\Entity\NodeType; use Drupal\graphql\GraphQL\ResolverBuilder; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Test the entity_definition data producer and friends. diff --git a/tests/src/Kernel/DataProducer/EntityMultipleTest.php b/tests/src/Kernel/DataProducer/EntityMultipleTest.php index a363526b5..3d9598db0 100644 --- a/tests/src/Kernel/DataProducer/EntityMultipleTest.php +++ b/tests/src/Kernel/DataProducer/EntityMultipleTest.php @@ -2,12 +2,10 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\NodeInterface; -use Drupal\Core\Entity\EntityInterface; -use Drupal\user\UserInterface; -use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Data producers Entity multiple test class. @@ -42,18 +40,6 @@ class EntityMultipleTest extends GraphQLTestBase { public function setUp(): void { parent::setUp(); - $this->entity = $this->getMockBuilder(NodeInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->user = $this->getMockBuilder(UserInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $content_type = NodeType::create([ 'type' => 'lorem', 'name' => 'ipsum', diff --git a/tests/src/Kernel/DataProducer/EntityReferenceTest.php b/tests/src/Kernel/DataProducer/EntityReferenceTest.php index 3197dd8bd..9d80fde6a 100644 --- a/tests/src/Kernel/DataProducer/EntityReferenceTest.php +++ b/tests/src/Kernel/DataProducer/EntityReferenceTest.php @@ -2,14 +2,11 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\field\Traits\EntityReferenceTestTrait; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; -use Drupal\node\NodeInterface; -use Drupal\Core\Entity\EntityInterface; -use Drupal\node\Entity\NodeType; use Drupal\node\Entity\Node; -use Drupal\user\UserInterface; +use Drupal\node\Entity\NodeType; +use Drupal\Tests\field\Traits\EntityReferenceTestTrait; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Tests the entity_reference data producers. @@ -19,24 +16,26 @@ class EntityReferenceTest extends GraphQLTestBase { use EntityReferenceTestTrait; + /** + * Test node that will be referenced. + * + * @var \Drupal\node\Entity\Node + */ + protected $referencedNode; + + /** + * Test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $node; + /** * {@inheritdoc} */ public function setUp(): void { parent::setUp(); - $this->entity = $this->getMockBuilder(NodeInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->user = $this->getMockBuilder(UserInterface::class) - ->disableOriginalConstructor() - ->getMock(); - $content_type1 = NodeType::create([ 'type' => 'test1', 'name' => 'ipsum1', @@ -51,19 +50,19 @@ public function setUp(): void { $this->createEntityReferenceField('node', 'test1', 'field_test1_to_test2', 'test1 lable', 'node', 'default', [], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); - $this->referenced_node = Node::create([ + $this->referencedNode = Node::create([ 'title' => 'Dolor2', 'type' => 'test2', ]); - $this->referenced_node->save(); - $this->referenced_node + $this->referencedNode->save(); + $this->referencedNode ->addTranslation('fr', ['title' => 'Dolor2 French']) ->save(); $this->node = Node::create([ 'title' => 'Dolor', 'type' => 'test1', - 'field_test1_to_test2' => $this->referenced_node->id(), + 'field_test1_to_test2' => $this->referencedNode->id(), ]); $this->node->save(); } @@ -78,7 +77,7 @@ public function testResolveEntityReference(): void { 'access' => TRUE, 'access_operation' => 'view', ]); - $this->assertEquals($this->referenced_node->id(), reset($result)->id()); + $this->assertEquals($this->referencedNode->id(), reset($result)->id()); $this->assertEquals('Dolor2', reset($result)->label()); $result = $this->executeDataProducer('entity_reference', [ @@ -88,7 +87,7 @@ public function testResolveEntityReference(): void { 'access_operation' => 'view', 'language' => 'fr', ]); - $this->assertEquals($this->referenced_node->id(), reset($result)->id()); + $this->assertEquals($this->referencedNode->id(), reset($result)->id()); $this->assertEquals('Dolor2 French', reset($result)->label()); } diff --git a/tests/src/Kernel/DataProducer/EntityTest.php b/tests/src/Kernel/DataProducer/EntityTest.php index 5d9d053ca..69496e60b 100644 --- a/tests/src/Kernel/DataProducer/EntityTest.php +++ b/tests/src/Kernel/DataProducer/EntityTest.php @@ -2,15 +2,17 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\node\NodeInterface; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultForbidden; use Drupal\Core\Entity\EntityInterface; -use Drupal\user\UserInterface; -use Drupal\node\Entity\NodeType; -use Drupal\node\Entity\Node; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Url; use Drupal\entity_test\Entity\EntityTestBundle; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; +use Drupal\user\UserInterface; /** * Data producers Entity test class. @@ -24,6 +26,41 @@ class EntityTest extends GraphQLTestBase { */ protected $node; + /** + * Mocked test entity. + * + * @var \Drupal\node\NodeInterface|\PHPUnit\Framework\MockObject\MockObject + */ + protected $entity; + + /** + * Mocked test entity interface. + * + * @var \Drupal\Core\Entity\EntityInterface + */ + protected $entityInterface; + + /** + * Mocked test user. + * + * @var \Drupal\user\UserInterface + */ + protected $user; + + /** + * Translated test entity. + * + * @var \Drupal\node\NodeInterface + */ + protected $translationFr; + + /** + * Translated test entity. + * + * @var \Drupal\node\NodeInterface + */ + protected $translationDe; + /** * {@inheritdoc} */ @@ -34,7 +71,7 @@ public function setUp(): void { ->disableOriginalConstructor() ->getMock(); - $this->entity_interface = $this->getMockBuilder(EntityInterface::class) + $this->entityInterface = $this->getMockBuilder(EntityInterface::class) ->disableOriginalConstructor() ->getMock(); @@ -64,11 +101,11 @@ public function setUp(): void { ]); $this->node->save(); - $this->translation_fr = $this->node->addTranslation('fr', ['title' => 'sit amet fr']); - $this->translation_fr->save(); + $this->translationFr = $this->node->addTranslation('fr', ['title' => 'sit amet fr']); + $this->translationFr->save(); - $this->translation_de = $this->node->addTranslation('de', ['title' => 'sit amet de']); - $this->translation_de->save(); + $this->translationDe = $this->node->addTranslation('de', ['title' => 'sit amet de']); + $this->translationDe->save(); \Drupal::service('content_translation.manager')->setEnabled('node', 'lorem', TRUE); } @@ -103,7 +140,7 @@ public function testResolveChanged(): void { $this->assertNull($this->executeDataProducer('entity_changed', [ 'format' => 'Y-m-d', - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -122,7 +159,7 @@ public function testResolveCreated(): void { $this->assertNull($this->executeDataProducer('entity_created', [ 'format' => 'Y-m-d', - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -164,9 +201,32 @@ public function testResolveLabel(): void { ->method('label') ->willReturn('Dummy label'); + $this->entity->expects($this->exactly(2)) + ->method('access') + ->willReturnCallback(static function (): AccessResult { + static $counter = 0; + switch ($counter) { + case 0: + $counter++; + return AccessResult::allowed(); + + case 1: + $counter++; + return AccessResult::forbidden(); + + default: + throw new \LogicException('The access() method should not have been called more than twice.'); + } + }) + ->with('view label', NULL, TRUE); + $this->assertEquals('Dummy label', $this->executeDataProducer('entity_label', [ 'entity' => $this->entity, ])); + + $this->assertNull($this->executeDataProducer('entity_label', [ + 'entity' => $this->entity, + ])); } /** @@ -199,7 +259,7 @@ public function testResolveOwner(): void { ])); $this->assertNull($this->executeDataProducer('entity_owner', [ - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -229,7 +289,7 @@ public function testResolvePublished(): void { ])); $this->assertNull($this->executeDataProducer('entity_published', [ - 'entity' => $this->entity_interface, + 'entity' => $this->entityInterface, ])); } @@ -239,7 +299,7 @@ public function testResolvePublished(): void { public function testResolveAccess(): void { $this->entity->expects($this->any()) ->method('access') - ->willReturn(FALSE); + ->willReturn(new AccessResultForbidden()); $this->assertFalse($this->executeDataProducer('entity_access', [ 'entity' => $this->entity, diff --git a/tests/src/Kernel/DataProducer/MenuTest.php b/tests/src/Kernel/DataProducer/MenuTest.php index 7dcb0c904..f1c6c51fd 100644 --- a/tests/src/Kernel/DataProducer/MenuTest.php +++ b/tests/src/Kernel/DataProducer/MenuTest.php @@ -2,11 +2,11 @@ namespace Drupal\Tests\graphql\Kernel\DataProducer; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\system\Entity\Menu; -use Drupal\menu_link_content\Entity\MenuLinkContent; -use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Menu\MenuLinkTreeElement; +use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\menu_link_content\Entity\MenuLinkContent; +use Drupal\system\Entity\Menu; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Data producers Menu test class. @@ -20,6 +20,20 @@ class MenuTest extends GraphQLTestBase { */ protected $menuLinkManager; + /** + * Test menu. + * + * @var \Drupal\system\Entity\Menu + */ + protected $menu; + + /** + * Test link tree array. + * + * @var \Drupal\Core\Menu\MenuLinkTreeElement[] + */ + protected $linkTree; + /** * {@inheritdoc} */ @@ -57,7 +71,6 @@ public function setUp(): void { $link = MenuLinkContent::create($parent); $link->save(); $links['parent'] = $link->getPluginId(); - $this->testLink = $link; $child_1 = $base_options + [ 'link' => ['uri' => 'internal:/menu-test/hierarchy/parent/child'], @@ -91,8 +104,8 @@ public function setUp(): void { $link->save(); $links['child-2'] = $link->getPluginId(); - $this->menuLinkTree = $this->container->get('menu.link_tree'); - $this->linkTree = $this->menuLinkTree->load('menu_test', new MenuTreeParameters()); + $menuLinkTree = $this->container->get('menu.link_tree'); + $this->linkTree = $menuLinkTree->load('menu_test', new MenuTreeParameters()); } /** diff --git a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php index 6e1a07bbc..76dec01a3 100644 --- a/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php +++ b/tests/src/Kernel/DataProducer/Routing/RouteEntityTest.php @@ -15,6 +15,90 @@ */ class RouteEntityTest extends GraphQLTestBase { + /** + * Published test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $publishedNode; + + /** + * French translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrPublished; + + /** + * German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDePublished; + + /** + * Unpublished test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $unpublishedNode; + + /** + * French translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrUnpublished; + + /** + * German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDeUnpublished; + + /** + * Unpublished test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $unpublishedToPublishedNode; + + /** + * Published french translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrUnpublishedToPublished; + + /** + * Published German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDeUnpublishedToPublished; + + /** + * Published test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $publishedToUnpublishedNode; + + /** + * Unpublished french translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationFrPublishedToUnpublished; + + /** + * Unpublished German translation of test node. + * + * @var \Drupal\node\Entity\Node + */ + protected $translationDePublishedToUnpublished; + /** * {@inheritdoc} */ @@ -30,66 +114,66 @@ public function setUp(): void { $content_type->save(); // Published node and published translations. - $this->published_node = Node::create([ + $this->publishedNode = Node::create([ 'title' => 'Test Event', 'type' => 'event', 'status' => NodeInterface::PUBLISHED, ]); - $this->published_node->save(); + $this->publishedNode->save(); - $this->translation_fr_published = $this->published_node->addTranslation('fr', ['title' => 'Test Event FR']); - $this->translation_fr_published->save(); + $this->translationFrPublished = $this->publishedNode->addTranslation('fr', ['title' => 'Test Event FR']); + $this->translationFrPublished->save(); - $this->translation_de_published = $this->published_node->addTranslation('de', ['title' => 'Test Event DE']); - $this->translation_de_published->save(); + $this->translationDePublished = $this->publishedNode->addTranslation('de', ['title' => 'Test Event DE']); + $this->translationDePublished->save(); // Unpublished node and unpublished translations. - $this->unpublished_node = Node::create([ + $this->unpublishedNode = Node::create([ 'title' => 'Test Unpublished Event', 'type' => 'event', 'status' => NodeInterface::NOT_PUBLISHED, ]); - $this->unpublished_node->save(); + $this->unpublishedNode->save(); - $this->translation_fr_unpublished = $this->unpublished_node->addTranslation('fr', ['title' => 'Test Unpublished Event FR']); - $this->translation_fr_unpublished->status = NodeInterface::NOT_PUBLISHED; - $this->translation_fr_unpublished->save(); + $this->translationFrUnpublished = $this->unpublishedNode->addTranslation('fr', ['title' => 'Test Unpublished Event FR']); + $this->translationFrUnpublished->setUnpublished(); + $this->translationFrUnpublished->save(); - $this->translation_de_unpublished = $this->unpublished_node->addTranslation('de', ['title' => 'Test Unpublished Event DE']); - $this->translation_de_unpublished->status = NodeInterface::NOT_PUBLISHED; - $this->translation_de_unpublished->save(); + $this->translationDeUnpublished = $this->unpublishedNode->addTranslation('de', ['title' => 'Test Unpublished Event DE']); + $this->translationDeUnpublished->setUnpublished(); + $this->translationDeUnpublished->save(); // Unpublished node to published translations. - $this->unpublished_to_published_node = Node::create([ + $this->unpublishedToPublishedNode = Node::create([ 'title' => 'Test Unpublished to Published Event', 'type' => 'event', 'status' => NodeInterface::NOT_PUBLISHED, ]); - $this->unpublished_to_published_node->save(); + $this->unpublishedToPublishedNode->save(); - $this->translation_fr_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('fr', ['title' => 'Test Unpublished to Published Event FR']); - $this->translation_fr_unpublished_to_published->status = NodeInterface::PUBLISHED; - $this->translation_fr_unpublished_to_published->save(); + $this->translationFrUnpublishedToPublished = $this->unpublishedToPublishedNode->addTranslation('fr', ['title' => 'Test Unpublished to Published Event FR']); + $this->translationFrUnpublishedToPublished->setPublished(); + $this->translationFrUnpublishedToPublished->save(); - $this->translation_de_unpublished_to_published = $this->unpublished_to_published_node->addTranslation('de', ['title' => 'Test Unpublished to Published Event DE']); - $this->translation_de_unpublished_to_published->status = NodeInterface::PUBLISHED; - $this->translation_de_unpublished_to_published->save(); + $this->translationDeUnpublishedToPublished = $this->unpublishedToPublishedNode->addTranslation('de', ['title' => 'Test Unpublished to Published Event DE']); + $this->translationDeUnpublishedToPublished->setPublished(); + $this->translationDeUnpublishedToPublished->save(); // Published node to unpublished translations. - $this->published_to_unpublished_node = Node::create([ + $this->publishedToUnpublishedNode = Node::create([ 'title' => 'Test Published to Unpublished Event', 'type' => 'event', 'status' => NodeInterface::PUBLISHED, ]); - $this->published_to_unpublished_node->save(); + $this->publishedToUnpublishedNode->save(); - $this->translation_fr_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('fr', ['title' => 'Test Published to Unpublished Event FR']); - $this->translation_fr_published_to_unpublished->status = NodeInterface::NOT_PUBLISHED; - $this->translation_fr_published_to_unpublished->save(); + $this->translationFrPublishedToUnpublished = $this->publishedToUnpublishedNode->addTranslation('fr', ['title' => 'Test Published to Unpublished Event FR']); + $this->translationFrPublishedToUnpublished->setUnpublished(); + $this->translationFrPublishedToUnpublished->save(); - $this->translation_de_published_to_unpublished = $this->published_to_unpublished_node->addTranslation('de', ['title' => 'Test Published to Unpublished Event DE']); - $this->translation_de_published_to_unpublished->status = NodeInterface::NOT_PUBLISHED; - $this->translation_de_published_to_unpublished->save(); + $this->translationDePublishedToUnpublished = $this->publishedToUnpublishedNode->addTranslation('de', ['title' => 'Test Published to Unpublished Event DE']); + $this->translationDePublishedToUnpublished->setUnpublished(); + $this->translationDePublishedToUnpublished->save(); \Drupal::service('content_translation.manager')->setEnabled('node', 'event', TRUE); } @@ -99,34 +183,34 @@ public function setUp(): void { */ public function testRouteEntity(): void { // Published node to published translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->published_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->publishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, ]); - $this->assertEquals($this->published_node->id(), $result->id()); - $this->assertEquals($this->published_node->label(), $result->label()); + $this->assertEquals($this->publishedNode->id(), $result->id()); + $this->assertEquals($this->publishedNode->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'fr', ]); - $this->assertEquals($this->translation_fr_published->id(), $result->id()); - $this->assertEquals($this->translation_fr_published->label(), $result->label()); + $this->assertEquals($this->translationFrPublished->id(), $result->id()); + $this->assertEquals($this->translationFrPublished->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'de', ]); - $this->assertEquals($this->translation_de_published->id(), $result->id()); - $this->assertEquals($this->translation_de_published->label(), $result->label()); + $this->assertEquals($this->translationDePublished->id(), $result->id()); + $this->assertEquals($this->translationDePublished->label(), $result->label()); // Unpublished node to unpublished translations. Make sure we are not // allowed to get the unpublished nodes or translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublished_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublishedNode->id()]); foreach ([NULL, 'fr', 'de'] as $lang) { $result = $this->executeDataProducer('route_entity', [ 'url' => $url, @@ -138,7 +222,7 @@ public function testRouteEntity(): void { // Unpublished node to published translations. Make sure we are not able to // get unpublished source, but we are able to get published translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublished_to_published_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->unpublishedToPublishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, @@ -151,27 +235,27 @@ public function testRouteEntity(): void { 'language' => 'fr', ]); - $this->assertEquals($this->translation_fr_unpublished_to_published->id(), $result->id()); - $this->assertEquals($this->translation_fr_unpublished_to_published->label(), $result->label()); + $this->assertEquals($this->translationFrUnpublishedToPublished->id(), $result->id()); + $this->assertEquals($this->translationFrUnpublishedToPublished->label(), $result->label()); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, 'language' => 'de', ]); - $this->assertEquals($this->translation_de_unpublished_to_published->id(), $result->id()); - $this->assertEquals($this->translation_de_unpublished_to_published->label(), $result->label()); + $this->assertEquals($this->translationDeUnpublishedToPublished->id(), $result->id()); + $this->assertEquals($this->translationDeUnpublishedToPublished->label(), $result->label()); // Published node to unpublished translations. Make sure we are able to get // published source, but we are not able to get unpublished translations. - $url = Url::fromRoute('entity.node.canonical', ['node' => $this->published_to_unpublished_node->id()]); + $url = Url::fromRoute('entity.node.canonical', ['node' => $this->publishedToUnpublishedNode->id()]); $result = $this->executeDataProducer('route_entity', [ 'url' => $url, ]); - $this->assertEquals($this->published_to_unpublished_node->id(), $result->id()); - $this->assertEquals($this->published_to_unpublished_node->label(), $result->label()); + $this->assertEquals($this->publishedToUnpublishedNode->id(), $result->id()); + $this->assertEquals($this->publishedToUnpublishedNode->label(), $result->label()); foreach (['fr', 'de'] as $lang) { $result = $this->executeDataProducer('route_entity', [ diff --git a/tests/src/Kernel/DataProducer/RoutingTest.php b/tests/src/Kernel/DataProducer/RoutingTest.php index d20ee1b1f..e523e7ca0 100644 --- a/tests/src/Kernel/DataProducer/RoutingTest.php +++ b/tests/src/Kernel/DataProducer/RoutingTest.php @@ -84,8 +84,8 @@ public function testRouteLoad(): void { * @covers \Drupal\graphql\Plugin\GraphQL\DataProducer\Routing\Url\UrlPath::resolve */ public function testUrlPath(): void { - $this->pathValidator = $this->container->get('path.validator'); - $url = $this->pathValidator->getUrlIfValidWithoutAccessCheck('/user/logout'); + $pathValidator = $this->container->get('path.validator'); + $url = $pathValidator->getUrlIfValidWithoutAccessCheck('/user/logout'); $result = $this->executeDataProducer('url_path', [ 'url' => $url, diff --git a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php index 58593ae0c..6a8ad512f 100644 --- a/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php @@ -12,6 +12,13 @@ */ class AutomaticPersistedQueriesTest extends GraphQLTestBase { + /** + * Test plugin. + * + * @var \Drupal\graphql\Plugin\PersistedQueryPluginInterface + */ + protected $pluginApq; + /** * {@inheritdoc} */ @@ -32,7 +39,7 @@ protected function setUp(): void { /** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */ $manager = $this->container->get('plugin.manager.graphql.persisted_query'); - $this->plugin_apq = $manager->createInstance('automatic_persisted_query'); + $this->pluginApq = $manager->createInstance('automatic_persisted_query'); } /** @@ -42,7 +49,7 @@ public function testAutomaticPersistedQueries(): void { // Before adding the persisted query plugins to the server, we want to make // sure that there are no existing plugins already there. $this->server->removeAllPersistedQueryInstances(); - $this->server->addPersistedQueryInstance($this->plugin_apq); + $this->server->addPersistedQueryInstance($this->pluginApq); $this->server->save(); $endpoint = $this->server->get('endpoint'); @@ -65,7 +72,7 @@ public function testAutomaticPersistedQueries(): void { // Post query to endpoint with a not matching hash. $content = json_encode(['query' => $query] + $parameters); - $request = Request::create($endpoint, 'POST', [], [], [], [], $content); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content); $result = $this->container->get('http_kernel')->handle($request); $this->assertSame(200, $result->getStatusCode()); $this->assertSame([ @@ -81,7 +88,7 @@ public function testAutomaticPersistedQueries(): void { $parameters['extensions']['persistedQuery']['sha256Hash'] = hash('sha256', $query); $content = json_encode(['query' => $query] + $parameters); - $request = Request::create($endpoint, 'POST', [], [], [], [], $content); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $content); $result = $this->container->get('http_kernel')->handle($request); $this->assertSame(200, $result->getStatusCode()); $this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE)); diff --git a/tests/src/Kernel/Framework/CsrfTest.php b/tests/src/Kernel/Framework/CsrfTest.php new file mode 100644 index 000000000..34d451b30 --- /dev/null +++ b/tests/src/Kernel/Framework/CsrfTest.php @@ -0,0 +1,191 @@ +setUpSchema($schema); + $this->mockResolver('Mutation', 'write', + function () { + $this->mutationTriggered = TRUE; + return TRUE; + } + ); + } + + /** + * Tests that a simple request from an evil origin is not executed. + * + * @dataProvider provideSimpleContentTypes + */ + public function testEvilOrigin(string $content_type): void { + $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [], [ + 'CONTENT_TYPE' => $content_type, + 'HTTP_ORIGIN' => 'https://evil.example.com', + ], '{ "query": "mutation { write }" }'); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertFalse($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(400, $response->getStatusCode()); + } + + /** + * Data provider for testContentTypeCsrf(). + */ + public function provideSimpleContentTypes(): array { + // Three content types that can be sent with simple no-cors POST requests. + return [ + ['text/plain'], + ['application/x-www-form-urlencoded'], + ['multipart/form-data'], + ]; + } + + /** + * Tests that a simple multipart form data no-cors request is not executed. + */ + public function testMultipartFormDataCsrf(): void { + $request = Request::create('https://example.com/graphql/test', 'POST', + [ + 'operations' => '[{ "query": "mutation { write }" }]', + ], + [], + [], + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://evil.example.com', + ] + ); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertFalse($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(400, $response->getStatusCode()); + $result = json_decode($response->getContent()); + $this->assertSame("Form requests must include a Apollo-Require-Preflight HTTP header or the Origin HTTP header value needs to be in the allowedOrigins in the CORS settings.", $result->message); + } + + /** + * Test that the JSON content types always work, cannot be forged with CSRF. + * + * @dataProvider provideAllowedJsonHeaders + */ + public function testAllowedJsonRequests(array $headers): void { + $request = Request::create('https://example.com/graphql/test', 'POST', [], [], [], + $headers, '{ "query": "mutation { write }" }'); + + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertTrue($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Data provider for testAllowedJsonRequests(). + */ + public function provideAllowedJsonHeaders(): array { + return [ + [['CONTENT_TYPE' => 'application/json']], + [['CONTENT_TYPE' => 'application/graphql']], + ]; + } + + /** + * Test that a form request with the correct headers against CSRF are allowed. + * + * @dataProvider provideAllowedFormRequests + */ + public function testAllowedFormRequests(array $headers, array $allowedDomains = []): void { + $request = Request::create('https://example.com/graphql/test', 'POST', + [ + 'operations' => '[{ "query": "mutation { write }" }]', + ], [], [], $headers); + + if (!empty($allowedDomains)) { + // Replace the QueryRouteEnhancer to inject CORS config we want to test. + $this->container->set('graphql.route_enhancer.query', new QueryRouteEnhancer([ + 'enabled' => TRUE, + 'allowedOrigins' => $allowedDomains, + ])); + } + /** @var \Symfony\Component\HttpFoundation\Response $response */ + $response = $this->container->get('http_kernel')->handle($request); + $this->assertTrue($this->mutationTriggered, 'Mutation was triggered'); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Data provider for testAllowedFormRequests(). + */ + public function provideAllowedFormRequests(): array { + return [ + // Omitting the Origin and Apollo-Require-Preflight is allowed. + [['CONTENT_TYPE' => 'multipart/form-data']], + // The custom Apollo-Require-Preflight header overrules any evil Origin + // header. + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_APOLLO_REQUIRE_PREFLIGHT' => 'test', + 'HTTP_ORIGIN' => 'https://evil.example.com', + ], + ], + // The Origin header alone with the correct domain is allowed. + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://example.com', + ], + ], + // The Origin header with an allowed domain. + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], + ['https://allowed.example.com'], + ], + // The Origin header with any allowed domain. + [ + [ + 'CONTENT_TYPE' => 'multipart/form-data', + 'HTTP_ORIGIN' => 'https://allowed.example.com', + ], + ['*'], + ], + ]; + } + +} diff --git a/tests/src/Kernel/Framework/DisabledResultCacheTest.php b/tests/src/Kernel/Framework/DisabledResultCacheTest.php index 4b8ba1081..31d5653cd 100644 --- a/tests/src/Kernel/Framework/DisabledResultCacheTest.php +++ b/tests/src/Kernel/Framework/DisabledResultCacheTest.php @@ -3,8 +3,8 @@ namespace Drupal\Tests\graphql\Kernel\Framework; use Drupal\Core\DependencyInjection\ContainerBuilder; -use Drupal\Tests\graphql\Kernel\GraphQLTestBase; use Drupal\graphql\Entity\Server; +use Drupal\Tests\graphql\Kernel\GraphQLTestBase; /** * Test disabled result cache. diff --git a/tests/src/Kernel/Framework/PersistedQueriesTest.php b/tests/src/Kernel/Framework/PersistedQueriesTest.php index 43daeb249..e28e0b5d9 100644 --- a/tests/src/Kernel/Framework/PersistedQueriesTest.php +++ b/tests/src/Kernel/Framework/PersistedQueriesTest.php @@ -44,13 +44,6 @@ protected function setUp(): void { $this->mockResolver('Query', 'field_three', []); $this->mockResolver('Link', 'url', 'https://www.ecosia.org'); $this->mockResolver('Link', 'title', 'Ecosia'); - - /** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */ - $manager = $this->container->get('plugin.manager.graphql.persisted_query'); - - $this->plugin_one = $manager->createInstance('persisted_query_plugin_one'); - $this->plugin_two = $manager->createInstance('persisted_query_plugin_two'); - $this->plugin_three = $manager->createInstance('persisted_query_plugin_three'); } /** @@ -62,13 +55,18 @@ public function testPersistedQueries(array $instanceIds, string $queryId, array // Before adding the persisted query plugins to the server, we want to make // sure that there are no existing plugins already there. $this->server->removeAllPersistedQueryInstances(); + + /** @var \Drupal\graphql\Plugin\PersistedQueryPluginManager $manager */ + $manager = $this->container->get('plugin.manager.graphql.persisted_query'); + foreach ($instanceIds as $index => $instanceId) { - $this->{$instanceId}->setWeight($index); - $this->server->addPersistedQueryInstance($this->{$instanceId}); + $plugin = $manager->createInstance("persisted_query_$instanceId"); + $plugin->setWeight($index); + $this->server->addPersistedQueryInstance($plugin); } $this->server->save(); - $result = $this->query($queryId, NULL, [], NULL, TRUE); + $result = $this->query($queryId, NULL, [], [], TRUE); $this->assertSame(200, $result->getStatusCode()); $this->assertSame($expected, json_decode($result->getContent(), TRUE)); diff --git a/tests/src/Kernel/Framework/ResultCacheTest.php b/tests/src/Kernel/Framework/ResultCacheTest.php index 30ea99b98..68ec90679 100644 --- a/tests/src/Kernel/Framework/ResultCacheTest.php +++ b/tests/src/Kernel/Framework/ResultCacheTest.php @@ -2,15 +2,16 @@ namespace Drupal\Tests\graphql\Kernel\Framework; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\Cache\Context\ContextCacheKeys; use Drupal\Core\Render\RenderContext; +use Drupal\graphql\Entity\Server; use Drupal\graphql\GraphQL\Execution\FieldContext; use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Prophecy\Argument; -use Drupal\graphql\Entity\Server; -use Drupal\Core\Cache\CacheableDependencyInterface; use GraphQL\Deferred; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; /** * Test query result caching. @@ -385,4 +386,34 @@ function ($a, $b, $c, $d, FieldContext $field) use ($renderer) { $this->assertEquals(200, $result->getStatusCode()); } + /** + * Ensure that a different operation name does not get a cached result. + */ + public function testOperationNameCaching(): void { + $dummy = $this->getMockBuilder(Server::class) + ->disableOriginalConstructor() + ->onlyMethods(['id']) + ->getMock(); + + // The dataproducer should be called twice because 2 differently named + // queries are not cached. + $dummy->expects($this->exactly(2)) + ->method('id') + ->willReturn('test'); + + // Use the same resolver for both fields. + foreach (['root', 'leakA'] as $field_name) { + $this->mockResolver('Query', $field_name, + function () use ($dummy) { + return $dummy->id(); + } + ); + } + + // First call is uncached. + $this->query('query one { root } query two { leakA }', NULL, [], [], FALSE, Request::METHOD_GET, 'one'); + // Second call is uncached. + $this->query('query one { root } query two { leakA }', NULL, [], [], FALSE, Request::METHOD_GET, 'two'); + } + } diff --git a/tests/src/Kernel/Framework/ResultTest.php b/tests/src/Kernel/Framework/ResultTest.php index f1b7acdb7..ff38ae0b5 100644 --- a/tests/src/Kernel/Framework/ResultTest.php +++ b/tests/src/Kernel/Framework/ResultTest.php @@ -58,7 +58,7 @@ public function testQuery(): void { * @coversClass \Drupal\graphql\Cache\RequestPolicy\DenyPost */ public function testPostQuery(): void { - $result = $this->query('query { root }', NULL, [], NULL, FALSE, Request::METHOD_POST); + $result = $this->query('query { root }', NULL, [], [], FALSE, Request::METHOD_POST); $this->assertSame(200, $result->getStatusCode()); $this->assertSame([ 'data' => [ diff --git a/tests/src/Kernel/Framework/TestFrameworkTest.php b/tests/src/Kernel/Framework/TestFrameworkTest.php index 824cc1c63..51802d268 100644 --- a/tests/src/Kernel/Framework/TestFrameworkTest.php +++ b/tests/src/Kernel/Framework/TestFrameworkTest.php @@ -2,9 +2,9 @@ namespace Drupal\Tests\graphql\Kernel\Framework; +use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Tests\graphql\Kernel\GraphQLTestBase; -use Drupal\Core\Cache\CacheableDependencyInterface; /** * Test the test framework. diff --git a/tests/src/Kernel/GraphQLTestBase.php b/tests/src/Kernel/GraphQLTestBase.php index bb8416d9c..5a1a259cd 100644 --- a/tests/src/Kernel/GraphQLTestBase.php +++ b/tests/src/Kernel/GraphQLTestBase.php @@ -10,8 +10,8 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\graphql\Traits\DataProducerExecutionTrait; -use Drupal\Tests\graphql\Traits\MockingTrait; use Drupal\Tests\graphql\Traits\HttpRequestTrait; +use Drupal\Tests\graphql\Traits\MockingTrait; use Drupal\Tests\graphql\Traits\QueryFileTrait; use Drupal\Tests\graphql\Traits\QueryResultAssertionTrait; use Drupal\Tests\graphql\Traits\SchemaPrinterTrait; diff --git a/tests/src/Kernel/ResolverBuilderTest.php b/tests/src/Kernel/ResolverBuilderTest.php index dd4808e33..d800923fe 100644 --- a/tests/src/Kernel/ResolverBuilderTest.php +++ b/tests/src/Kernel/ResolverBuilderTest.php @@ -2,8 +2,8 @@ namespace Drupal\Tests\graphql\Kernel; -use GraphQL\Deferred; use Drupal\graphql\GraphQL\Resolver\ResolverInterface; +use GraphQL\Deferred; /** * Tests that the resolver builder behaves correctly. @@ -231,6 +231,26 @@ public function testFromContext(): void { $this->assertResults($query, [], ['tree' => ['context' => ['myContext' => 'my context value']]]); } + /** + * @covers ::cond + */ + public function testSingleCond(): void { + $this->mockResolver('Query', 'me', $this->builder->cond([ + [ + $this->builder->fromValue(FALSE), + $this->builder->fromValue('This should resolve into null.'), + ], + ])); + + $query = <<assertResults($query, [], ['me' => NULL]); + } + /** * @covers ::cond */ diff --git a/tests/src/Traits/HttpRequestTrait.php b/tests/src/Traits/HttpRequestTrait.php index 69fd4e1ca..b7b4174bb 100644 --- a/tests/src/Traits/HttpRequestTrait.php +++ b/tests/src/Traits/HttpRequestTrait.php @@ -27,22 +27,28 @@ trait HttpRequestTrait { * The server instance. * @param array $variables * Query variables. - * @param array|null $extensions + * @param array $extensions * The query extensions. * @param bool $persisted * Flag if the query is actually the identifier of a persisted query. * @param string $method * Method, GET or POST. + * @param string $operationName + * Optional operation name if $query contains multiple operations. * * @return \Symfony\Component\HttpFoundation\Response * The http response object. */ - protected function query($query, $server = NULL, array $variables = [], array $extensions = NULL, $persisted = FALSE, string $method = Request::METHOD_GET) { + protected function query( + string $query, + ?Server $server = NULL, + array $variables = [], + array $extensions = [], + bool $persisted = FALSE, + string $method = Request::METHOD_GET, + string $operationName = '' + ) { $server = $server ?: $this->server; - if (!($server instanceof Server)) { - throw new \LogicException('Invalid server.'); - } - $endpoint = $this->server->get('endpoint'); $extensions = !empty($extensions) ? ['extensions' => $extensions] : []; // If the persisted flag is true, then instead of sending the full query to @@ -52,11 +58,14 @@ protected function query($query, $server = NULL, array $variables = [], array $e $query_key => $query, 'variables' => $variables, ] + $extensions; + if ($operationName) { + $data['operationName'] = $operationName; + } if ($method === Request::METHOD_GET) { $request = Request::create($endpoint, $method, $data); } else { - $request = Request::create($endpoint, $method, [], [], [], [], json_encode($data)); + $request = Request::create($endpoint, $method, [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data)); } return $this->container->get('http_kernel')->handle($request); @@ -81,7 +90,7 @@ protected function batchedQueries(array $queries, ServerInterface $server = NULL $queries = json_encode($queries); $endpoint = $this->server->get('endpoint'); - $request = Request::create($endpoint, 'POST', [], [], [], [], $queries); + $request = Request::create($endpoint, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $queries); return $this->container->get('http_kernel')->handle($request); } diff --git a/tests/src/Traits/MockingTrait.php b/tests/src/Traits/MockingTrait.php index 1f1691db7..85f3a74a8 100644 --- a/tests/src/Traits/MockingTrait.php +++ b/tests/src/Traits/MockingTrait.php @@ -2,14 +2,14 @@ namespace Drupal\Tests\graphql\Traits; +use Drupal\graphql\Entity\Server; use Drupal\graphql\GraphQL\Resolver\Callback; use Drupal\graphql\GraphQL\Resolver\ResolverInterface; use Drupal\graphql\GraphQL\Resolver\Value; +use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\graphql\Plugin\GraphQL\Schema\SdlSchemaPluginBase; use Drupal\graphql\Plugin\SchemaExtensionPluginManager; use Drupal\graphql\Plugin\SchemaPluginManager; -use Drupal\graphql\Entity\Server; -use Drupal\graphql\GraphQL\ResolverRegistry; use Drupal\Tests\RandomGeneratorTrait; /**