UserResourceTestBase.php

Same filename in other branches
  1. 9 core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php
  2. 8.9.x core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
  3. 8.9.x core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php
  4. 10 core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php

Namespace

Drupal\Tests\user\Functional\Rest

File

core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Tests\user\Functional\Rest;

use Drupal\Core\Url;
use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Attributes\Before;
abstract class UserResourceTestBase extends EntityResourceTestBase {
    
    /**
     * {@inheritdoc}
     */
    protected static $modules = [
        'user',
    ];
    
    /**
     * {@inheritdoc}
     */
    protected static $entityTypeId = 'user';
    
    /**
     * {@inheritdoc}
     */
    protected static $patchProtectedFieldNames = [
        'changed' => NULL,
    ];
    
    /**
     * @var \Drupal\user\UserInterface
     */
    protected $entity;
    
    /**
     * {@inheritdoc}
     */
    protected static $labelFieldName = 'name';
    
    /**
     * {@inheritdoc}
     */
    protected static $firstCreatedEntityId = 4;
    
    /**
     * {@inheritdoc}
     */
    protected static $secondCreatedEntityId = 5;
    
    /**
     * Marks some tests as skipped because XML cannot be deserialized.
     */
    public function userResourceTestBaseSkipTests() : void {
        if (in_array($this->name(), [
            'testPatchDxForSecuritySensitiveBaseFields',
            'testPatchSecurityOtherUser',
        ], TRUE)) {
            if (static::$format === 'xml') {
                $this->markTestSkipped('Deserialization of the XML format is not supported.');
            }
            if (static::$auth === FALSE) {
                $this->markTestSkipped('The anonymous user is never allowed to modify itself.');
            }
        }
    }
    
    /**
     * {@inheritdoc}
     */
    protected function setUpAuthorization($method) {
        switch ($method) {
            case 'GET':
                $this->grantPermissionsToTestedRole([
                    'access user profiles',
                ]);
                break;
            case 'POST':
            case 'PATCH':
            case 'DELETE':
                $this->grantPermissionsToTestedRole([
                    'administer users',
                ]);
                break;
        }
    }
    
    /**
     * {@inheritdoc}
     */
    protected function createEntity() {
        // Create a "Llama" user.
        $user = User::create([
            'created' => 123456789,
        ]);
        $user->setUsername('Llama')
            ->setChangedTime(123456789)
            ->activate()
            ->save();
        return $user;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function createAnotherEntity() {
        
        /** @var \Drupal\user\UserInterface $user */
        $user = $this->entity
            ->createDuplicate();
        $user->setUsername($user->label() . '_dupe');
        $user->save();
        return $user;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getExpectedNormalizedEntity() {
        return [
            'uid' => [
                [
                    'value' => 3,
                ],
            ],
            'uuid' => [
                [
                    'value' => $this->entity
                        ->uuid(),
                ],
            ],
            'langcode' => [
                [
                    'value' => 'en',
                ],
            ],
            'name' => [
                [
                    'value' => 'Llama',
                ],
            ],
            'created' => [
                [
                    'value' => (new \DateTime())->setTimestamp(123456789)
                        ->setTimezone(new \DateTimeZone('UTC'))
                        ->format(\DateTime::RFC3339),
                    'format' => \DateTime::RFC3339,
                ],
            ],
            'changed' => [
                [
                    'value' => (new \DateTime())->setTimestamp($this->entity
                        ->getChangedTime())
                        ->setTimezone(new \DateTimeZone('UTC'))
                        ->format(\DateTime::RFC3339),
                    'format' => \DateTime::RFC3339,
                ],
            ],
            'default_langcode' => [
                [
                    'value' => TRUE,
                ],
            ],
        ];
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getNormalizedPostEntity() {
        return [
            'name' => [
                [
                    'value' => 'Drama llama',
                ],
            ],
        ];
    }
    
    /**
     * Tests PATCHing security-sensitive base fields of the logged in account.
     */
    public function testPatchDxForSecuritySensitiveBaseFields() : void {
        $this->initAuthentication();
        $this->provisionEntityResource();
        
        /** @var \Drupal\user\UserInterface $user */
        $user = static::$auth ? $this->account : User::load(0);
        // @todo Remove the array_diff_key() call in https://www.drupal.org/node/2821077.
        $original_normalization = array_diff_key($this->serializer
            ->normalize($user, static::$format), [
            'created' => TRUE,
            'changed' => TRUE,
            'name' => TRUE,
        ]);
        // Since this test must be performed by the user that is being modified,
        // we cannot use $this->getUrl().
        $url = $user->toUrl()
            ->setOption('query', [
            '_format' => static::$format,
        ]);
        $request_options = [
            RequestOptions::HEADERS => [
                'Content-Type' => static::$mimeType,
            ],
        ];
        $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
        // Test case 1: changing email.
        $normalization = $original_normalization;
        $normalization['mail'] = [
            [
                'value' => 'new-email@example.com',
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // DX: 422 when changing email without providing the password.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response, FALSE, FALSE, FALSE, FALSE);
        $normalization['pass'] = [
            [
                'existing' => 'wrong',
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // DX: 422 when changing email while providing a wrong password.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response, FALSE, FALSE, FALSE, FALSE);
        $normalization['pass'] = [
            [
                'existing' => $this->account->passRaw,
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // 200 for well-formed request.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceResponse(200, FALSE, $response);
        // Test case 2: changing password.
        $normalization = $original_normalization;
        $new_password = $this->randomString();
        $normalization['pass'] = [
            [
                'value' => $new_password,
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // DX: 422 when changing password without providing the current password.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the Password.\n", $response, FALSE, FALSE, FALSE, FALSE);
        $normalization['pass'][0]['existing'] = $this->account->pass_raw;
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // 200 for well-formed request.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceResponse(200, FALSE, $response);
        // Verify that we can log in with the new password.
        $this->assertRpcLogin($user->getAccountName(), $new_password);
        // Update password in $this->account, prepare for future requests.
        $this->account->passRaw = $new_password;
        $this->initAuthentication();
        $request_options = [
            RequestOptions::HEADERS => [
                'Content-Type' => static::$mimeType,
            ],
        ];
        $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
        // Test case 3: changing name.
        $normalization = $original_normalization;
        $normalization['name'] = [
            [
                'value' => 'Cooler Llama',
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // DX: 403 when modifying username without required permission.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceErrorResponse(403, "Access denied on updating field 'name'.", $response);
        $this->grantPermissionsToTestedRole([
            'change own username',
        ]);
        // 200 for well-formed request.
        $response = $this->request('PATCH', $url, $request_options);
        $this->assertResourceResponse(200, FALSE, $response);
        // Verify that we can log in with the new username.
        $this->assertRpcLogin('Cooler Llama', $new_password);
    }
    
    /**
     * Verifies that logging in with the given username and password works.
     *
     * @param string $username
     *   The username to log in with.
     * @param string $password
     *   The password to log in with.
     */
    protected function assertRpcLogin($username, $password) {
        $request_body = [
            'name' => $username,
            'pass' => $password,
        ];
        $request_options = [
            RequestOptions::HEADERS => [],
            RequestOptions::BODY => $this->serializer
                ->encode($request_body, 'json'),
        ];
        $response = $this->request('POST', Url::fromRoute('user.login.http')->setRouteParameter('_format', 'json'), $request_options);
        $this->assertSame(200, $response->getStatusCode());
    }
    
    /**
     * Tests PATCHing security-sensitive base fields to change other users.
     */
    public function testPatchSecurityOtherUser() : void {
        $this->initAuthentication();
        $this->provisionEntityResource();
        
        /** @var \Drupal\user\UserInterface $user */
        $user = $this->account;
        $original_normalization = array_diff_key($this->serializer
            ->normalize($user, static::$format), [
            'changed' => TRUE,
        ]);
        // Since this test must be performed by the user that is being modified,
        // we cannot use $this->getUrl().
        $url = $user->toUrl()
            ->setOption('query', [
            '_format' => static::$format,
        ]);
        $request_options = [
            RequestOptions::HEADERS => [
                'Content-Type' => static::$mimeType,
            ],
        ];
        $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions('PATCH'));
        $normalization = $original_normalization;
        $normalization['mail'] = [
            [
                'value' => 'new-email@example.com',
            ],
        ];
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($normalization, static::$format);
        // Try changing user 1's email.
        $user1 = [
            'mail' => [
                [
                    'value' => 'another_email_address@example.com',
                ],
            ],
            'uid' => [
                [
                    'value' => 1,
                ],
            ],
            'name' => [
                [
                    'value' => 'another_user_name',
                ],
            ],
            'pass' => [
                [
                    'existing' => $this->account->passRaw,
                ],
            ],
            'uuid' => [
                [
                    'value' => '2e9403a4-d8af-4096-a116-624710140be0',
                ],
            ],
        ] + $original_normalization;
        $request_options[RequestOptions::BODY] = $this->serializer
            ->encode($user1, static::$format);
        $response = $this->request('PATCH', $url, $request_options);
        // Ensure the email address has not changed.
        $this->assertEquals('admin@example.com', $this->entityStorage
            ->loadUnchanged(1)
            ->getEmail());
        $this->assertResourceErrorResponse(403, "Access denied on updating field 'uid'. The entity ID cannot be changed.", $response);
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getExpectedUnauthorizedAccessMessage($method) {
        switch ($method) {
            case 'GET':
                return "The 'access user profiles' permission is required.";
            case 'PATCH':
                return "Users can only update their own account, unless they have the 'administer users' permission.";
            case 'DELETE':
                return "The 'cancel account' permission is required.";
            default:
                return parent::getExpectedUnauthorizedAccessMessage($method);
        }
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getExpectedUnauthorizedEntityAccessCacheability($is_authenticated) {
        // @see \Drupal\user\UserAccessControlHandler::checkAccess()
        $result = parent::getExpectedUnauthorizedEntityAccessCacheability($is_authenticated);
        if (!\Drupal::currentUser()->hasPermission('access user profiles')) {
            $result->addCacheContexts([
                'user',
            ]);
        }
        return $result;
    }
    
    /**
     * {@inheritdoc}
     */
    protected function getExpectedCacheContexts() {
        return [
            'url.site',
            // Due to the 'mail' field's access varying by user.
'user',
        ];
    }

}

Classes

Title Deprecated Summary
UserResourceTestBase

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.