diff --git a/core/modules/dblog/src/Plugin/rest/resource/DBLogResource.php b/core/modules/dblog/src/Plugin/rest/resource/DBLogResource.php index 56740ca..26ec4f3 100644 --- a/core/modules/dblog/src/Plugin/rest/resource/DBLogResource.php +++ b/core/modules/dblog/src/Plugin/rest/resource/DBLogResource.php @@ -8,7 +8,7 @@ namespace Drupal\dblog\Plugin\rest\resource; use Drupal\rest\Plugin\ResourceBase; -use Drupal\rest\ResourceResponse; +use Drupal\rest\CacheableResourceResponse; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -33,7 +33,7 @@ class DBLogResource extends ResourceBase { * @param int $id * The ID of the watchdog log entry. * - * @return \Drupal\rest\ResourceResponse + * @return \Drupal\rest\CacheableResourceResponse * The response containing the log entry. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException @@ -43,7 +43,7 @@ public function get($id = NULL) { $record = db_query("SELECT * FROM {watchdog} WHERE wid = :wid", array(':wid' => $id)) ->fetchAssoc(); if (!empty($record)) { - return new ResourceResponse($record); + return new CacheableResourceResponse($record); } throw new NotFoundHttpException(t('Log entry with ID @id was not found', array('@id' => $id))); diff --git a/core/modules/rest/src/CacheableResourceResponse.php b/core/modules/rest/src/CacheableResourceResponse.php new file mode 100644 index 0000000..9f58244 --- /dev/null +++ b/core/modules/rest/src/CacheableResourceResponse.php @@ -0,0 +1,34 @@ +responseData = $data; + parent::__construct('', $status, $headers); + } + +} diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 4b4d027..39f5d1a 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -9,8 +9,9 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageException; +use Drupal\rest\CacheableResourceResponse; use Drupal\rest\Plugin\ResourceBase; -use Drupal\rest\ResourceResponse; +use Drupal\rest\UncacheableResourceResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -39,7 +40,7 @@ class EntityResource extends ResourceBase { * @param \Drupal\Core\Entity\EntityInterface $entity * The entity object. * - * @return \Drupal\rest\ResourceResponse + * @return \Drupal\rest\UncacheableResourceResponse * The response containing the entity with its accessible fields. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException @@ -54,7 +55,7 @@ public function get(EntityInterface $entity) { } } - $response = new ResourceResponse($entity, 200); + $response = new CacheableResourceResponse($entity, 200); // Make the response use the entity's cacheability metadata. // @todo include access cacheability metadata, for the access checks above. $response->addCacheableDependency($entity); @@ -109,8 +110,7 @@ public function post(EntityInterface $entity = NULL) { // 201 Created responses have an empty body. $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); - $response = new ResourceResponse(NULL, 201, ['Location' => $url->getGeneratedUrl()]); - $response->addCacheableDependency($url); + $response = new UncacheableResourceResponse(NULL, 201, ['Location' => $url->getGeneratedUrl()]); return $response; } catch (EntityStorageException $e) { @@ -166,7 +166,7 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity $this->logger->notice('Updated entity %type with ID %id.', array('%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id())); // Update responses have an empty body. - return new ResourceResponse(NULL, 204); + return new UncacheableResourceResponse(NULL, 204); } catch (EntityStorageException $e) { throw new HttpException(500, 'Internal Server Error', $e); @@ -193,7 +193,7 @@ public function delete(EntityInterface $entity) { $this->logger->notice('Deleted entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id())); // Delete responses have an empty body. - return new ResourceResponse(NULL, 204); + return new UncacheableResourceResponse(NULL, 204); } catch (EntityStorageException $e) { throw new HttpException(500, 'Internal Server Error', $e); diff --git a/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php new file mode 100644 index 0000000..762e2f5 --- /dev/null +++ b/core/modules/rest/src/Plugin/rest/resource/UserRegistrationResource.php @@ -0,0 +1,207 @@ +userSettings = $user_settings; + $this->currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.factory')->get('rest'), + $container->get('config.factory')->get('user.settings'), + $container->get('current_user') + ); + } + + /** + * Responds to user registration POST request. + * + * @param \Drupal\user\UserInterface $account + * The user account entity. + * + * @return \Drupal\rest\UncacheableResourceResponse + * The HTTP response object. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function post(UserInterface $account = NULL) { + if ($account == NULL) { + throw new BadRequestHttpException('No user account data for registration received.'); + } + + // POSTed user accounts must not have an ID set, because we always + // want to create new entities here. + if (!$account->isNew()) { + throw new BadRequestHttpException('An ID has been set and only new user accounts can be registered.'); + } + + // The current resource only allows anonymous users to register users. + if (!$this->currentUser->isAnonymous()) { + throw new AccessDeniedHttpException('Only anonymous users can register users.'); + } + $approvalSettings = $this->userSettings->get('register'); + // Verify that the current user can register a user account. + if ($approvalSettings == USER_REGISTER_ADMINISTRATORS_ONLY) { + throw new AccessDeniedHttpException('Only administrators can register users.'); + } + // If current user can register accounts then let's block the new registered + // user if admin approval is needed. + elseif ($approvalSettings == USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) { + $account->block(); + } + + // Only check 'edit' permissions for fields that were actually submitted by + // the user. Field access makes no difference between 'create'and 'update', + // so the 'edit' operation is used here. + foreach ($account->_restSubmittedFields as $key => $field_name) { + if (!$account->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on creating field '$field_name'."); + } + } + + // Make sure that the user entity is valid (email and name are valid). + $this->validate($account); + // Create the account. + $account->save(); + + $register = $this->userSettings->get('register'); + // No E-mail verification is required. Activating the user. + if ($register == 'visitors') { + if (!$this->userSettings->get('verify_mail')) { + // Notification will be sent if activated. + $account->activate(); + // Save changes to apply active status to the account. + $account->save(); + } + // No administrator approval required. + else { + _user_mail_notify('register_no_approval_required', $account); + } + } + // Administrator approval required. + elseif ($register == 'visitors_admin_approval') { + _user_mail_notify('register_pending_approval', $account); + } + + $url = $account->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); + $response = new UncacheableResourceResponse(NULL, 201, ['Location' => $url->getGeneratedUrl()]); + + return $response; + } + + /** + * {@inheritdoc} + */ + public function routes() { + $collection = new RouteCollection(); + + $route = $this->getBaseRoute('/entity/user/register', 'POST'); + + // Restrict the incoming HTTP Content-type header to the known serialization + // formats. + $route->addRequirements(['_content_type_format' => implode('|', $this->serializerFormats)]); + $collection->add("$this->pluginId", $route); + + return $collection; + } + + /** + * Verifies that the whole entity does not violate any validation constraints. + * + * @param \Drupal\user\UserInterface $entity + * The entity object. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * If validation errors are found. + */ + protected function validate(UserInterface $entity) { + $violations = $entity->validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + + if (count($violations) > 0) { + $message = "Unprocessable Entity: validation failed.\n"; + foreach ($violations as $violation) { + $message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . "\n"; + } + // Instead of returning a generic 400 response we use the more specific + // 422 Unprocessable Entity code from RFC 4918. That way clients can + // distinguish between general syntax errors in bad serializations (code + // 400) and semantic errors in well-formed requests (code 422). + throw new HttpException(422, $message); + } + } + +} diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 5a04cd8..6dcb39f 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -7,6 +7,7 @@ namespace Drupal\rest; +use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; @@ -103,26 +104,35 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } // Serialize the outgoing data for the response, if available. - if ($response instanceof ResourceResponse && $data = $response->getResponseData()) { - // Serialization can invoke rendering (e.g., generating URLs), but the - // serialization API does not provide a mechanism to collect the - // bubbleable metadata associated with that (e.g., language and other - // contexts), so instead, allow those to "leak" and collect them here in - // a render context. - // @todo Add test coverage for language negotiation contexts in - // https://www.drupal.org/node/2135829. - $context = new RenderContext(); - $output = $this->container->get('renderer')->executeInRenderContext($context, function() use ($serializer, $data, $format) { - return $serializer->serialize($data, $format); - }); - $response->setContent($output); - if (!$context->isEmpty()) { - $response->addCacheableDependency($context->pop()); - } + if ($response instanceof ResourceResponseInterface && $data = $response->getResponseData()) { + // Cacheable Response. + if ($response instanceof CacheableResponseInterface) { + // Serialization can invoke rendering (e.g., generating URLs), but the + // serialization API does not provide a mechanism to collect the + // bubbleable metadata associated with that (e.g., language and other + // contexts), so instead, allow those to "leak" and collect them here in + // a render context. + // @todo Add test coverage for language negotiation contexts in + // https://www.drupal.org/node/2135829. + $context = new RenderContext(); + $output = $this->container->get('renderer')->executeInRenderContext($context, function () use ($serializer, $data, $format) { + return $serializer->serialize($data, $format); + }); + $response->setContent($output); + if (!$context->isEmpty()) { + $response->addCacheableDependency($context->pop()); + } - $response->headers->set('Content-Type', $request->getMimeType($format)); - // Add rest settings config's cache tags. - $response->addCacheableDependency($this->container->get('config.factory')->get('rest.settings')); + $response->headers->set('Content-Type', $request->getMimeType($format)); + // Add rest settings config's cache tags. + $response->addCacheableDependency($this->container->get('config.factory')->get('rest.settings')); + } + else { + // Uncacheable Response. + $output = $serializer->serialize($data, $format); + $response->setContent($output); + $response->headers->set('Content-Type', $request->getMimeType($format)); + } } return $response; } diff --git a/core/modules/rest/src/ResourceResponse.php b/core/modules/rest/src/ResourceResponse.php deleted file mode 100644 index 2919fb1..0000000 --- a/core/modules/rest/src/ResourceResponse.php +++ /dev/null @@ -1,57 +0,0 @@ -responseData = $data; - parent::__construct('', $status, $headers); - } - - /** - * Returns response data that should be serialized. - * - * @return mixed - * Response data that should be serialized. - */ - public function getResponseData() { - return $this->responseData; - } -} diff --git a/core/modules/rest/src/ResourceResponseInterface.php b/core/modules/rest/src/ResourceResponseInterface.php new file mode 100644 index 0000000..a889dae --- /dev/null +++ b/core/modules/rest/src/ResourceResponseInterface.php @@ -0,0 +1,23 @@ +responseData; + } + +} diff --git a/core/modules/rest/src/Tests/RegisterUserTest.php b/core/modules/rest/src/Tests/RegisterUserTest.php new file mode 100644 index 0000000..a5e2d58 --- /dev/null +++ b/core/modules/rest/src/Tests/RegisterUserTest.php @@ -0,0 +1,108 @@ +config('rest.settings'); + $settings = []; + $resources = [ + ['type' => 'rest_user_registration', 'method' => 'POST'], + ]; + $format = 'hal_json'; + $auth = $this->defaultAuth; + foreach ($resources as $resource) { + $settings[$resource['type']][$resource['method']]['supported_formats'][] = $format; + $settings[$resource['type']][$resource['method']]['supported_auth'] = $auth; + $config->set('resources', $settings); + $config->save(); + } + $this->rebuildCache(); + + // Add registration permission to anonymous user. + Role::load(RoleInterface::ANONYMOUS_ID) + ->grantPermission('restful post rest_user_registration') + ->save(); + } + + /** + * Tests user registration from REST. + */ + protected function testRegisterUser() { + + // New user info to be serialized. + $data = [ + "_links" => + [ + "type" => ["href" => $GLOBALS['base_url'] . "/rest/type/user/user"] + ], + "langcode" => [ + [ + "value" => "en" + ] + ], + "name" => [ + [ + "value" => "Druplicon" + ] + ], + "mail" => [ + [ + "value" => "druplicon@example.com" + ] + ], + "pass" => [ + [ + "value" => "SuperSecretPassword" + ] + ] + ]; + + // Create a JSON version for the user entity we want to create. + $serialized = $this->container->get('serializer')->serialize($data, 'hal_json'); + + // Post to the REST service to register the user. + $this->httpRequest('entity/user/register', 'POST', $serialized, 'application/hal+json'); + $this->assertResponse('201', 'HTTP response code is correct.'); + + // Obtain the uid from the header. + $url_parts = explode('/', $this->drupalGetHeader('location')); + $id = end($url_parts); + $this->assertHeader('Location', $GLOBALS['base_url'] . '/user/' . $id); + + $this->assertTrue((bool) $this->container->get('entity.query') + ->get('user') + ->condition('name', 'Druplicon') + ->range(0, 1) + ->count() + ->execute(), 'The user was created as expected'); + } + +} diff --git a/core/modules/rest/src/UncacheableResourceResponse.php b/core/modules/rest/src/UncacheableResourceResponse.php new file mode 100644 index 0000000..308275d --- /dev/null +++ b/core/modules/rest/src/UncacheableResourceResponse.php @@ -0,0 +1,30 @@ +responseData = $data; + parent::__construct('', $status, $headers); + } +} diff --git a/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php new file mode 100644 index 0000000..6ac54f0 --- /dev/null +++ b/core/modules/rest/tests/src/Unit/UserRegistrationResourceTest.php @@ -0,0 +1,320 @@ +logger = $this->getMock(LoggerInterface::class); + + $this->userSettings = $this->getMockBuilder(ImmutableConfig::class) + ->setMethods(['get']) + ->disableOriginalConstructor() + ->getMock(); + + $this->currentUser = $this->getMockBuilder(AccountInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings, $this->currentUser); + $this->reflection = new \ReflectionClass($this->testClass); + + } + + /** + * Helper method to set a protected method as accessible. + * + * @param string $method + * The protected method. + * + * @return \ReflectionMethod + */ + public function getProtectedMethod($method) { + $method = $this->reflection->getMethod($method); + $method->setAccessible(TRUE); + + return $method; + } + + /** + * Tests that the user entity does not violate any validation constraints. + */ + public function testValidate() { + $violations = $this->getMockBuilder(EntityConstraintViolationList::class) + ->setMethods(['filterByFieldAccess']) + ->disableOriginalConstructor() + ->getMock(); + + $violations->expects($this->once()) + ->method('filterByFieldAccess') + ->will($this->returnValue(array())); + + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('validate') + ->will($this->returnValue($violations)); + + $method = $this->getProtectedMethod('validate'); + + // No exception is thrown. + $method->invokeArgs($this->testClass, [$entity]); + } + + /** + * Tests that error validation is thrown as expected. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\HttpException + * @expectedException UserRegistrationResourceTest::ERROR_MESSAGE + */ + public function testFailedValidate() { + $violation1 = $this->getMock(ConstraintViolationInterface::class); + + $violation1->expects($this->once()) + ->method('getPropertyPath') + ->will($this->returnValue('property_path')); + + $violation1->expects($this->once()) + ->method('getMessage') + ->will($this->returnValue('message')); + + $violation2 = $this->getMock(ConstraintViolationInterface::class); + + $violation2->expects($this->once()) + ->method('getPropertyPath') + ->will($this->returnValue('property_path_2')); + + $violation2->expects($this->once()) + ->method('getMessage') + ->will($this->returnValue('message_2')); + + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $violations = $this->getMockBuilder(EntityConstraintViolationList::class) + ->setConstructorArgs([$entity, [$violation1, $violation2]]) + ->setMethods(['filterByFieldAccess']) + ->getMock(); + + $violations->expects($this->once()) + ->method('filterByFieldAccess') + ->will($this->returnValue([])); + + $entity->expects($this->once()) + ->method('validate') + ->will($this->returnValue($violations)); + + $method = $this->getProtectedMethod('validate'); + + $method->invoke($this->testClass, $entity); + } + + /** + * Tests that an exception is thrown when no data provided for the account. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage No user account data for registration received. + */ + public function testEmptyPost() { + $this->testClass->post(NULL); + } + + /** + * Tests that only new user accounts can be registered. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException + * @expectedExceptionMessage An ID has been set and only new user accounts can be registered. + */ + public function testExistedEntityPost() { + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('isNew') + ->will($this->returnValue(FALSE)); + + $this->testClass->post($entity); + } + + /** + * Tests that admin permissions are required to register a user account. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage Only administrators can register users. + */ + public function testRegistrationAdminOnlyPost() { + $this->currentUser->expects($this->once()) + ->method('isAnonymous') + ->will($this->returnValue(TRUE)); + + $this->userSettings->expects($this->once()) + ->method('get') + ->will($this->returnValue(USER_REGISTER_ADMINISTRATORS_ONLY)); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings, $this->currentUser); + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('isNew') + ->will($this->returnValue(TRUE)); + + $this->testClass->post($entity); + } + + /** + * Tests that only anonymous users can register users. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage Only anonymous users can register users. + */ + public function testRegistrationAnonymousOnlyPost() { + $this->currentUser->expects($this->once()) + ->method('isAnonymous') + ->will($this->returnValue(FALSE)); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings, $this->currentUser); + + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('isNew') + ->will($this->returnValue(TRUE)); + + $this->testClass->post($entity); + } + + /** + * Tests access denied on creating field. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * @expectedExceptionMessage Access denied on creating field 'test_field'. + */ + public function testFieldAccessValidation() { + $this->currentUser->expects($this->once()) + ->method('isAnonymous') + ->will($this->returnValue(TRUE)); + + $this->testClass = new UserRegistrationResource([], 'plugin_id', '', [], $this->logger, $this->userSettings, $this->currentUser); + + $entity = $this->getMockBuilder(User::class) + ->disableOriginalConstructor() + ->getMock(); + + $entity->expects($this->once()) + ->method('isNew') + ->will($this->returnValue(TRUE)); + + $field = $this->getMockBuilder(FieldItemList::class) + ->setMethods(['access']) + ->disableOriginalConstructor() + ->getMock(); + + $field->expects($this->once()) + ->method('access') + ->will($this->returnValue(FALSE)); + + $entity->expects($this->once()) + ->method('get') + ->will($this->returnValue($field)); + + $entity->expects($this->once()) + ->method('__get') + ->will($this->returnValue(['test' => 'test_field'])); + + $this->testClass->post($entity); + } + +}