Highly Opinionated Thoughts on Programming

by Elnur Abdurrakhimov


Symfony: Simplifying Password Encoding by Creating a Password Encoder Right in the DIC

May 13, 2014


Let’s say you have a UserManager class that controls the application rules pertaining to the User class. One of those rules is to encode a user’s password if her plain password is set. Here’s a test that would test that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
namespace Example\Manager;

use PHPUnit_Framework_TestCase;  
use Example\Model\User;  
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;  
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;

class UserManagerTest extends PHPUnit_Framework_TestCase  
{
    const PLAIN_PASSWORD = 'password';
    const ENCODED_PASSWORD = 'encoded-password';

    /**
     * @test
     */
    public function shouldEncodePasswordIfPlainPasswordIsSet()
    {
        $encoderFactory = $this->getMock(EncoderFactoryInterface::class);
        $userManager = new UserManager($encoderFactory);

        $encoder = $this->getMock(PasswordEncoderInterface::class);

        $user = new User([
            'plainPassword' => self::PLAIN_PASSWORD,
        ]);

        $encoderFactory
            ->expects($this->once())
            ->method('getEncoder')
            ->with($user)
            ->willReturn($encoder);

        $encoder
            ->expects($this->once())
            ->method('encodePassword')
            ->with(self::PLAIN_PASSWORD, $user->getSalt())
            ->willReturn(self::ENCODED_PASSWORD);

        $userManager->save($user);
        $this->assertEquals(self::ENCODED_PASSWORD, $user->getPassword());
    }
}

And here’s an implementation of UserManager that would pass that test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
namespace Example\Manager;

use JMS\DiExtraBundle\Annotation\Inject;  
use JMS\DiExtraBundle\Annotation\InjectParams;  
use JMS\DiExtraBundle\Annotation\Service;  
use Example\Model\User;  
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

/**
 * @Service("user_manager")
 */
class UserManager  
{
    /**
     * @var EncoderFactoryInterface
     */
    private $encoderFactory;

    /**
     * @InjectParams({
     *     "encoderFactory" = @Inject("security.encoder_factory")
     * })
     *
     * @param EncoderFactoryInterface $encoderFactory
     */
    public function __construct(EncoderFactoryInterface $encoderFactory)
    {
        $this->encoderFactory = $encoderFactory;
    }

    /**
     * @param User $user
     */
    public function save(User $user)
    {
        $encoder = $this->encoderFactory->getEncoder($user);
        $password = $encoder->encodePassword($user->getPlainPassword(), $user->getSalt());
        $user->setPassword($password);
    }
}

We’re green, but the problem with that test is that a mock returns another mock. Two levels of mocks is not the worst you could see in the real ugly world, but it’s a design smell anyway.

But you have no choice, right? You have to inject the password encoder factory to get the right encoder for your user class, right? Well, kinda. There’s a better option: to create a password encoder for the user class by a service factory and inject it directly.

Here’s how the new test would look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace Example\Manager;

use PHPUnit_Framework_TestCase;  
use Example\Model\User;  
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;

class UserManagerTest extends PHPUnit_Framework_TestCase  
{
    const PLAIN_PASSWORD = 'password';
    const ENCODED_PASSWORD = 'encoded-password';

    /**
     * @test
     */
    public function shouldEncodePasswordIfPlainPasswordExists()
    {
        $passwordEncoder = $this->getMock(PasswordEncoderInterface::class);
        $userManager = new UserManager($passwordEncoder);

        $user = new User([
            'plainPassword' => self::PLAIN_PASSWORD,
        ]);

        $passwordEncoder
            ->expects($this->once())
            ->method('encodePassword')
            ->with(self::PLAIN_PASSWORD, $user->getSalt())
            ->willReturn(self::ENCODED_PASSWORD);

        $userManager->save($user);
        $this->assertEquals(self::ENCODED_PASSWORD, $user->getPassword());
    }
}

And here’s a new implementation of UserManager to pass that test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
namespace Example\Manager;

use JMS\DiExtraBundle\Annotation\Inject;  
use JMS\DiExtraBundle\Annotation\InjectParams;  
use JMS\DiExtraBundle\Annotation\Service;  
use Example\Model\User;  
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;

/**
 * @Service("user_manager")
 */
class UserManager  
{
    /**
     * @var PasswordEncoderInterface
     */
    private $passwordEncoder;

    /**
     * @InjectParams({
     *     "passwordEncoder" = @Inject("password_encoder")
     * })
     *
     * @param PasswordEncoderInterface $passwordEncoder
     */
    public function __construct(PasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    /**
     * @param User $user
     */
    public function save(User $user)
    {
        $password = $this->passwordEncoder->encodePassword($user->getPlainPassword(), $user->getSalt());
        $user->setPassword($password);
    }
}

As you can see in the test, the second level of mocks is gone. It’s much better now.

And the main part of all this is creating that password_encoder service that’s being inject into the user_manager’s service constructor. Here’s how you could do it in YAML in your config.yml or some other file like services.yml that you import into config.yml:

1
2
3
4
5
6
7
services:  
    password_encoder:
        class: Symfony\Component\Security\Core\Tests\Encoder\PasswordEncoder
        factory_service: security.encoder_factory
        factory_method: getEncoder
        arguments:
            - Example\Model\User

That’s it. By generating that password_encoder service via security.encoder_factory, you make both your code and tests cleaner.

That’s one of those examples, when striving to write clean tests leads to cleaner code.



© Elnur Abdurrakhimov