Search code examples
symfonydoctrine-ormphpunitsymfony4

Issue programmatically authenticating Users for PhpUnit functional test - Unmanaged by Doctrine - Symfony 4.3


I'm trying to get a simple "200 Response" test to work for a part of a website requiring an authenticated user. I think I've got the creation of the Session working, as during debugging the Controller function is called and a User is retrieved (using $this->getUser()).

However, afterwards the function fails with the following message:

1) App\Tests\Controller\SecretControllerTest::testIndex200Response
expected other status code for 'http://localhost/secret_url/':
error:
    Multiple non-persisted new entities were found through the given association graph:

 * A new entity was found through the relationship 'App\Entity\User#role' that was not configured to cascade persist operations for entity: ROLE_FOR_USER. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade
persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}).
 * A new entity was found through the relationship 'App\Entity\User#secret_property' that was not configured to cascade persist operations for entity: test123. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade pe
rsist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). (500 Internal Server Error)

Failed asserting that 500 matches expected 200.

This would make sense if this was not already stored in the (MySQL) database and retrieved with Doctrine. The records are created using Fixtures on each run/for each test. This is why in the Controller $this->getUser() functions as expected.

The test I'm wanting to work:

public function testIndex200Response(): void
{
    $client = $this->getAuthenticatedSecretUserClient();

    $this->checkPageLoadResponse($client, 'http://localhost/secret_url/');
}

Get a user:

protected function getAuthenticatedSecretUserClient(): HttpKernelBrowser
{
    $this->loadFixtures(
        [
            RoleFixture::class,
            SecretUserFixture::class,
        ]
    );

    /** @var User $user */
    $user = $this->entityManager->getRepository(User::class)->findOneBy(['username' => 'secret_user']);

    $client = self::createClient(
        [],
        [
            'PHP_AUTH_USER' => $user->getUsername(),
            'PHP_AUTH_PW'   => $user->getPlainPassword(),
        ]
    );

    $this->createClientSession($user, $client);

    return $client;
}

Create a session:

// Based on https://symfony.com/doc/current/testing/http_authentication.html#using-a-faster-authentication-mechanism-only-for-tests
protected function createClientSession(User $user, HttpKernelBrowser $client): void
{
    $authenticatedGuardToken = new PostAuthenticationGuardToken($user, 'chain_provider', $user->getRoles());
    $tokenStorage            = new TokenStorage();
    $tokenStorage->setToken($authenticatedGuardToken);

    $session = self::$container->get('session');
    $session->set('_security_<security_context>', serialize($authenticatedGuardToken));
    $session->save();

    $cookie = new Cookie($session->getName(), $session->getId());
    $client->getCookieJar()->set($cookie);

    self::$container->set('security.token_storage', $tokenStorage);
}

This works for the creating of the client, session and cookie.

When the Request is executed to the $url in the first function, it gets into the endpoint, confirming the User is indeed authenticated.

According to the documentation here a User should be "refreshed" from via the configured provider (using Doctrine in this case) to check if a given object matches a stored object.

[..] At the beginning of the next request, it's deserialized and then passed to your user provider to "refresh" it (e.g. Doctrine queries for a fresh user).

I would expect this would also ensure that the session User is replaced with a Doctrine managed User object to prevent the error above.

How can I go about solving that the User in the session becomes a managed User during PhpUnit testing?

(Note: the production code works without any issue, this problem only arises during testing (legacy code now starting to get tests))


Solution

  • Ok, had multiple issues, but got it working doing the following:

    First, was creating a Client using incorrect password, I was creating (in Fixtures) User entities with username and password being identical. The function getPlainPassword, though present in an interface, was not something stored, so was a blank value.

    Corrected code:

    $client = self::createClient(
        [],
        [
            'PHP_AUTH_USER' => $user->getUsername(),
            'PHP_AUTH_PW'   => $user->getUsername(),
        ]
    );
    

    Next, a User not being refreshed took some more.

    In config/packages/security.yaml, add the following:

    security:
      firewalls:
        test:
          security: ~
    

    This is to create the "test" key, as creating that immediately in the next file will cause a permission denied error. In config/packages/test/security.yaml, create the following:

    security:
      providers:
        test_user_provider:
          id: App\Tests\Functional\Security\UserProvider
      firewalls:
        test:
          http_basic:
            provider: test_user_provider
    

    This adds a custom UserProvider specifically for testing purposes (hence usage App\Tests\ namespace). You must register this service in your config/services_test.yaml:

    services:
        App\Tests\Functional\Security\:
            resource: '../tests/Functional/Security'
    

    Not sure you'll need it, but I added in config/packages/test/routing.yaml the following:

    parameters:
        protocol: http
    

    As PhpUnit is testing via CLI, there by default is no secure connection, can vary by environment so see if you need it.

    Lastly, config for test framework in config/packages/test/framework.yaml:

    framework:
        test: true
        session:
            storage_id: session.storage.mock_file
    

    All of the above config (apart from the http bit) is to ensure that a custom UserProvider will be used to provider User objects during testing.

    This might excessive for others, but our setup (legacy) has some custom work for providing Users for authentication (which seems very related but far out of my current issue scope).

    Back on to the UserProvider, it's setup like so:

    namespace App\Tests\Functional\Security;
    
    use App\Entity\User;
    use App\Repository\UserRepository;
    use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    
    class UserProvider implements UserProviderInterface
    {
        /** @var UserRepository */
        private $userRepository;
    
        public function __construct(UserRepository $userRepository)
        {
            $this->userRepository = $userRepository;
        }
    
        public function loadUserByUsername($username)
        {
            try {
                return $this->userRepository->getByUsername($username);
            } catch (UserNotFoundException $e) {
                throw new UsernameNotFoundException("Username: $username unknown");
            }
        }
    
        public function refreshUser(UserInterface $user)
        {
            return $this->loadUserByUsername($user->getUsername());
        }
    
        public function supportsClass($class)
        {
            return User::class === $class;
        }
    }
    

    Note: should you use this, you need to have a getByUsername function in your UserRepository.


    Please note, this might not be the solution for you. Maybe you need to change it up, maybe it's completely off. Either way, thought to leave a solution for any future souls.