diff --git a/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php new file mode 100644 index 000000000..7fadf097e --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/CreateEntity.php @@ -0,0 +1,115 @@ +entityTypeManager = $container->get('entity_type.manager'); + return $instance; + } + + /** + * Resolve the values for this producer. + */ + public function resolve(string $entity_type, array $values, string $entity_return_key, ?bool $save, $context) { + $storage = $this->entityTypeManager->getStorage($entity_type); + $accessHandler = $this->entityTypeManager->getAccessControlHandler($entity_type); + + // Infer the bundle type from the response and return an error if the entity + // type expects one, but one is not present. + $entity_type = $this->entityTypeManager->getDefinition($entity_type); + $bundle = $entity_type->getKey('bundle') && !empty($values[$entity_type->getKey('bundle')]) ? $values[$entity_type->getKey('bundle')] : NULL; + if ($entity_type->getKey('bundle') && !$bundle) { + return [ + 'errors' => [$this->t('Entity type being created requires a bundle, but none was present.')], + ]; + }; + + // Ensure the user has access to create this kind of entity. + $access = $accessHandler->createAccess($bundle, NULL, [], TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'errors' => [$access instanceof AccessResultReasonInterface && $access->getReason() ? $access->getReason() : $this->t('Access was forbidden.')], + ]; + } + + $entity = $storage->create($values); + + // Core does not have a concept of create access for fields, so edit access + // is used instead. This is consistent with how other Drupal APIs handle + // field based create access. + $field_access_errors = []; + foreach ($values as $field_name => $value) { + $create_access = $entity->get($field_name)->access('edit', NULL, TRUE); + if (!$create_access->isALlowed()) { + $field_access_errors[] = sprintf('%s: %s', $field_name, $create_access instanceof AccessResultReasonInterface ? $create_access->getReason() : $this->t('Access was forbidden.')); + } + } + if (!empty($field_access_errors)) { + return ['errors' => $field_access_errors]; + } + + if ($violation_messages = $this->getViolationMessages($entity)) { + return ['errors' => $violation_messages]; + } + + if ($save) { + $entity->save(); + } + return [ + $entity_return_key => $entity, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php new file mode 100644 index 000000000..7637af093 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/DeleteEntity.php @@ -0,0 +1,46 @@ +access('delete', NULL, TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'was_successful' => FALSE, + 'errors' => [$access instanceof AccessResultReasonInterface ? $access->getReason() : 'Access was forbidden.'], + ]; + } + + $entity->delete(); + return [ + 'was_successful' => TRUE, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php new file mode 100644 index 000000000..722f1a9f2 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/Entity/UpdateEntity.php @@ -0,0 +1,81 @@ +access('update', NULL, TRUE); + $context->addCacheableDependency($access); + if (!$access->isAllowed()) { + return [ + 'errors' => [$access instanceof AccessResultReasonInterface ? $access->getReason() : 'Access was forbidden.'], + ]; + } + + // Filter out keys the user does not have access to update, this may include + // things such as the owner of the entity or the ID of the entity. + $update_fields = array_filter($values, function (string $field_name) use ($entity, $context) { + if (!$entity->hasField($field_name)) { + throw new \Exception("Could not update '$field_name' field, since it does not exist on the given entity."); + } + $access = $entity->{$field_name}->access('edit', NULL, TRUE); + $context->addCacheableDependency($access); + return $access->isAllowed(); + }, ARRAY_FILTER_USE_KEY); + + // Hydrate the entity with the values. + foreach ($update_fields as $field_name => $field_value) { + $entity->set($field_name, $field_value); + } + + if ($violation_messages = $this->getViolationMessages($entity)) { + return [ + 'errors' => $violation_messages, + ]; + } + + // Once access has been granted, the save can be committed and the entity + // can be returned to the client. + $entity->save(); + return [ + $entity_return_key => $entity, + ]; + } + +} diff --git a/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php new file mode 100644 index 000000000..0506b8890 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/EntityValidationTrait.php @@ -0,0 +1,44 @@ +validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + + if ($violations->count() > 0) { + $violation_messages = []; + foreach ($violations as $violation) { + $violation_messages[] = sprintf('%s: %s', $violation->getPropertyPath(), strip_tags($violation->getMessage())); + } + return $violation_messages; + } + return []; + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php new file mode 100644 index 000000000..1763e52fb --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/CreateEntityTest.php @@ -0,0 +1,120 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + } + + /** + * Test creating entities. + */ + public function testCreateEntity() { + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [ + 'type' => 'lorem', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('Access was forbidden.', (string) $result['errors'][0]); + + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [ + 'type' => 'lorem' + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame([ + 'title: This value should not be null.', + ], $result['errors']); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'save' => TRUE, + 'values' => [ + 'type' => 'lorem', + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + $this->assertFalse($result['foo']->isNew()); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'save' => FALSE, + 'values' => [ + 'type' => 'lorem', + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + $this->assertTrue($result['foo']->isNew()); + } + + /** + * Test field access when creating entities. + */ + public function testCreateEntityFieldAccess() { + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [ + 'type' => 'lorem', + 'nid' => 123, + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('nid: The entity ID cannot be changed.', (string) $result['errors'][0]); + } + + /** + * Test creating an entity with a missing bundle. + */ + public function testCreateEntityMissingBundle() { + $result = $this->executeDataProducer($this->pluginId, [ + 'entity_type' => 'node', + 'values' => [], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('Entity type being created requried a bundle, but none was present.', (string) $result['errors'][0]); + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php b/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php new file mode 100644 index 000000000..86358c566 --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/DeleteEntityTest.php @@ -0,0 +1,59 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + + $node = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + ]); + $node->save(); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $node, + ]); + $this->assertSame("The 'delete any lorem content' permission is required.", $result['errors'][0]); + + $account = $this->createUser(['bypass node access']); + $this->setCurrentUser($account); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $node, + ]); + $this->assertTrue($result['was_successful']); + $this->assertNull(Node::load($node->id())); + } + +} diff --git a/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php new file mode 100644 index 000000000..48134a690 --- /dev/null +++ b/tests/src/Kernel/DataProducer/Entity/UpdateEntityTest.php @@ -0,0 +1,119 @@ + 'lorem', + 'name' => 'ipsum', + ]); + $content_type->save(); + } + + /** + * Test updating an entity. + */ + public function testUpdateEntity() { + $entity = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + 'uuid' => 'adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', + ]); + $entity->save(); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame("The 'edit any lorem content' permission is required.", $result['errors'][0]); + + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'type' => 'something_wacky', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame('type.0.target_id: The referenced entity (node_type: something_wacky) does not exist.', $result['errors'][0]); + + // Reload the article, since the data producer hydrates the passed in entity + // with values, but does not reset them. + $entity = Node::load($entity->id()); + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'title' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertEquals('bar', $result['foo']->label()); + + // Fields which do not pass field-access checks are filtered out. + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'uuid' => '1c41245d-d173-4861-8524-8dd50ef7668d', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertArrayNotHasKey('errors', $result); + $this->assertEquals('adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', $result['foo']->uuid()); + } + + /** + * Test updating an entity with an invalid field. + */ + public function testUpdateEntityInvalidField() { + $entity = Node::create([ + 'type' => 'lorem', + 'title' => 'foo', + 'uuid' => 'adf834bd-9e70-4c2a-bf8a-3ef2382e1d78', + ]); + $entity->save(); + $this->setCurrentUser($this->createUser(['bypass node access', 'access content'])); + + $this->expectExceptionMessage("Could not update 'not_a_real_field' field, since it does not exist on the given entity."); + $result = $this->executeDataProducer($this->pluginId, [ + 'entity' => $entity, + 'values' => [ + 'not_a_real_field' => 'bar', + ], + 'entity_return_key' => 'foo', + ]); + $this->assertSame("The 'edit any lorem content' permission is required.", $result['errors'][0]); + } + +}