From 5fabcffb44a189d00aada1cb05a58922ab6d7742 Mon Sep 17 00:00:00 2001 From: catch <catch@35733.no-reply.drupal.org> Date: Fri, 13 Sep 2019 12:21:53 +0100 Subject: [PATCH] Issue #3057175 by Wim Leers, gabesullice, Berdir: Implementation of user name in JSON:API can result in overwriting data --- .../jsonapi/src/Controller/EntityResource.php | 15 +++++ .../src/JsonApiResource/ResourceObject.php | 22 +++++-- .../Normalizer/ContentEntityDenormalizer.php | 8 +++ .../ResourceType/ResourceTypeRepository.php | 9 +++ .../jsonapi_test_user.info.yml | 4 ++ .../jsonapi_test_user.module | 17 ++++++ .../tests/src/Functional/ResourceTestBase.php | 40 +++++++------ .../jsonapi/tests/src/Functional/UserTest.php | 58 ++++++++++++++++++- .../JsonApiDocumentTopLevelNormalizerTest.php | 4 +- 9 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml create mode 100644 core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 8c0e027aa32d..ca76ab31adf4 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -242,6 +242,13 @@ public function createIndividual(ResourceType $resource_type, Request $request) }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) { return $resource_type->hasField($internal_field_name); }); + // User resource objects contain a read-only attribute that is not a + // real field on the user entity type. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. + if ($resource_type->getEntityTypeId() === 'user') { + $valid_names = array_diff($valid_names, [$resource_type->getPublicName('display_name')]); + } foreach ($valid_names as $field_name) { $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE); if (!$field_access->isAllowed()) { @@ -317,6 +324,14 @@ public function patchIndividual(ResourceType $resource_type, EntityInterface $en $data += ['attributes' => [], 'relationships' => []]; $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships'])); + // User resource objects contain a read-only attribute that is not a real + // field on the user entity type. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. + if ($entity->getEntityTypeId() === 'user') { + $field_names = array_diff($field_names, [$resource_type->getPublicName('display_name')]); + } + array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) { $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name); return $destination; diff --git a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php index 0540248c9b9c..2505d006c20a 100644 --- a/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php +++ b/core/modules/jsonapi/src/JsonApiResource/ResourceObject.php @@ -15,6 +15,7 @@ use Drupal\jsonapi\ResourceType\ResourceType; use Drupal\jsonapi\Revisions\VersionByRel; use Drupal\jsonapi\Routing\Routes; +use Drupal\user\UserInterface; /** * Represents a JSON:API resource object. @@ -281,11 +282,15 @@ protected static function extractContentEntityFields(ResourceType $resource_type [$resource_type, 'isFieldEnabled'] ); - // Special handling for user entities. - // @todo Improve in https://www.drupal.org/project/drupal/issues/3057175. + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. For example, this is useful when + // displaying the name of a node's author. + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. $entity_type = $entity->getEntityType(); - if ($entity_type->id() == 'user') { - $fields[static::getLabelFieldName($entity)]->value = $entity->label(); + if ($entity_type->id() == 'user' && $resource_type->isFieldEnabled('display_name')) { + assert($entity instanceof UserInterface); + $display_name = $resource_type->getPublicName('display_name'); + $output[$display_name] = $entity->getDisplayName(); } // Return a sub-array of $output containing the keys in $enabled_fields. @@ -294,6 +299,7 @@ protected static function extractContentEntityFields(ResourceType $resource_type $public_field_name = $resource_type->getPublicName($field_name); $output[$public_field_name] = $field_value; } + return $output; } @@ -308,9 +314,13 @@ protected static function extractContentEntityFields(ResourceType $resource_type */ protected static function getLabelFieldName(EntityInterface $entity) { $label_field_name = $entity->getEntityType()->getKey('label'); - // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands. + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. This is useful when displaying the + // name of a node's author. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. if ($entity->getEntityTypeId() === 'user') { - $label_field_name = 'name'; + $label_field_name = 'display_name'; } return $label_field_name; } diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php index cd821d497564..287f2cb14050 100644 --- a/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php +++ b/core/modules/jsonapi/src/Normalizer/ContentEntityDenormalizer.php @@ -47,6 +47,14 @@ protected function prepareInput(array $data, ResourceType $resource_type, $forma $bundle_key = $entity_type_definition->getKey('bundle'); $uuid_key = $entity_type_definition->getKey('uuid'); + // User resource objects contain a read-only attribute that is not a real + // field on the user entity type. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. + if ($entity_type_id === 'user') { + $data = array_diff_key($data, array_flip([$resource_type->getPublicName('display_name')])); + } + // Translate the public fields into the entity fields. foreach ($data as $public_field_name => $field_value) { $internal_name = $resource_type->getInternalName($public_field_name); diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php index 48563444f53e..ba609359dda9 100644 --- a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php +++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php @@ -244,6 +244,15 @@ protected static function getFieldMapping(array $field_names, EntityTypeInterfac $mapping[$field_name] = TRUE; } + // Special handling for user entities that allows a JSON:API user agent to + // access the display name of a user. This is useful when displaying the + // name of a node's author. + // @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields() + // @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254. + if ($entity_type->id() === 'user') { + $mapping['display_name'] = TRUE; + } + return $mapping; } diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml new file mode 100644 index 000000000000..179eda4da52d --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.info.yml @@ -0,0 +1,4 @@ +name: 'JSON:API user tests' +type: module +package: Testing +core: 8.x diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module new file mode 100644 index 000000000000..4056bd64d269 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_user/jsonapi_test_user.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Support module for JSON:API user hooks testing. + */ + +use Drupal\Core\Session\AccountInterface; + +/** + * Implements hook_user_format_name_alter(). + */ +function jsonapi_test_user_user_format_name_alter(&$name, AccountInterface $account) { + if ($account->isAnonymous()) { + $name = 'User ' . $account->id(); + } +} diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php index 6257adb4d82e..006e2b414718 100644 --- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @@ -136,14 +136,11 @@ abstract class ResourceTestBase extends BrowserTestBase { protected static $secondCreatedEntityId = 3; /** - * Optionally specify which field is the 'label' field. - * - * Some entities specify a 'label_callback', but not a 'label' entity key. - * For example: User. + * Specify which field is the 'label' field for testing a POST edge case. * * @var string|null * - * @see ::getInvalidNormalizedEntityToCreate() + * @see ::testPostIndividual() */ protected static $labelFieldName = NULL; @@ -1883,7 +1880,9 @@ public function testPostIndividual() { $parseable_valid_request_body = Json::encode($this->getPostDocument()); /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */ $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type')); - $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); + if ($this->entity->getEntityType()->hasKey('label')) { + $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); + } $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument())); $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument())); $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument())); @@ -1939,13 +1938,14 @@ public function testPostIndividual() { $response = $this->request('POST', $url, $request_options); $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE); - $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; - - // DX: 422 when invalid entity: multiple values sent for single-value field. - $response = $this->request('POST', $url, $request_options); - $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; - $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); - $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + if ($this->entity->getEntityType()->hasKey('label')) { + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + // DX: 422 when invalid entity: multiple values sent for single-value field. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->getKey('label'); + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + } $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; @@ -2050,6 +2050,7 @@ public function testPostIndividual() { // 500 when creating an entity with a duplicate UUID. $doc = $this->getModifiedEntityForPostTesting(); $doc['data']['id'] = $uuid; + $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; $request_options[RequestOptions::BODY] = Json::encode($doc); @@ -2148,13 +2149,14 @@ public function testPatchIndividual() { $response = $this->request('PATCH', $url, $request_options); $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); - $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; - // DX: 422 when invalid entity: multiple values sent for single-value field. - $response = $this->request('PATCH', $url, $request_options); - $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; - $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); - $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + if ($this->entity->getEntityType()->hasKey('label')) { + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + $response = $this->request('PATCH', $url, $request_options); + $label_field = $this->entity->getEntityType()->getKey('label'); + $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); + $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); + } $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; diff --git a/core/modules/jsonapi/tests/src/Functional/UserTest.php b/core/modules/jsonapi/tests/src/Functional/UserTest.php index 2277714de8ed..e9aa7b6bf319 100644 --- a/core/modules/jsonapi/tests/src/Functional/UserTest.php +++ b/core/modules/jsonapi/tests/src/Functional/UserTest.php @@ -22,7 +22,7 @@ class UserTest extends ResourceTestBase { /** * {@inheritdoc} */ - public static $modules = ['user']; + public static $modules = ['user', 'jsonapi_test_user']; /** * {@inheritdoc} @@ -56,7 +56,7 @@ class UserTest extends ResourceTestBase { /** * {@inheritdoc} */ - protected static $labelFieldName = 'name'; + protected static $labelFieldName = 'display_name'; /** * {@inheritdoc} @@ -138,6 +138,7 @@ protected function getExpectedDocument() { 'self' => ['href' => $self_url], ], 'attributes' => [ + 'display_name' => 'Llama', 'created' => '1973-11-29T21:33:09+00:00', 'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), 'default_langcode' => TRUE, @@ -441,7 +442,7 @@ public function testCollectionContainsAnonymousUser() { $this->assertCount(4, $doc['data']); $this->assertSame(User::load(0)->uuid(), $doc['data'][0]['id']); - $this->assertSame('Anonymous', $doc['data'][0]['attributes']['name']); + $this->assertSame('User 0', $doc['data'][0]['attributes']['display_name']); } /** @@ -551,4 +552,55 @@ public function testCollectionFilterAccess() { $this->assertSame($user_b->uuid(), $doc['data'][0]['id']); } + /** + * Tests users with altered display names. + */ + public function testResaveAccountName() { + $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); + $this->setUpAuthorization('PATCH'); + + $original_name = $this->entity->get('name')->value; + + $url = Url::fromRoute('jsonapi.user--user.individual', ['entity' => $this->entity->uuid()]); + $request_options = []; + $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); + + $response = $this->request('GET', $url, $request_options); + + // Send the unchanged data back. + $request_options[RequestOptions::BODY] = (string) $response->getBody(); + $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; + $response = $this->request('PATCH', $url, $request_options); + $this->assertEquals(200, $response->getStatusCode()); + + // Load the user entity again, make sure the name was not changed. + $this->entityStorage->resetCache(); + $updated_user = $this->entityStorage->load($this->entity->id()); + $this->assertEquals($original_name, $updated_user->get('name')->value); + } + + /** + * {@inheritdoc} + */ + protected function getModifiedEntityForPostTesting() { + $modified = parent::getModifiedEntityForPostTesting(); + $modified['data']['attributes']['name'] = $this->randomMachineName(); + return $modified; + } + + /** + * {@inheritdoc} + */ + protected function makeNormalizationInvalid(array $document, $entity_key) { + if ($entity_key === 'label') { + $document['data']['attributes']['name'] = [ + 0 => $document['data']['attributes']['name'], + 1 => 'Second Title', + ]; + return $document; + } + return parent::makeNormalizationInvalid($document, $entity_key); + } + } diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php index b4ba1b3b1dc7..99bd557e4177 100644 --- a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php +++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -230,7 +230,7 @@ public function testNormalize() { 'field_image', ], 'user--user' => [ - 'name', + 'display_name', ], ], 'include' => [ @@ -279,7 +279,7 @@ public function testNormalize() { $this->assertTrue(empty($normalized['meta']['omitted'])); $this->assertSame($this->user->uuid(), $normalized['included'][0]['id']); $this->assertSame('user--user', $normalized['included'][0]['type']); - $this->assertSame('user1', $normalized['included'][0]['attributes']['name']); + $this->assertSame('user1', $normalized['included'][0]['attributes']['display_name']); $this->assertCount(1, $normalized['included'][0]['attributes']); $this->assertSame($this->term1->uuid(), $normalized['included'][1]['id']); $this->assertSame('taxonomy_term--tags', $normalized['included'][1]['type']); -- GitLab