Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

User Test + Plain Password

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We have a pretty nice DragonTreasureResourceTest, so let's bootstrap one for User.

Bootstrapping the User Test

Create a new PHP class called, how about, UserResourceTest. Make it extend our custom ApiTestCase, then we just need to use ResetDatabase:

Tip

To use Foundry factories in a test, also add a use Factories; trait to the top of your test class. Things worked without that in this case, but in the future, you'll likely get an error.

... lines 1 - 2
namespace App\Tests\Functional;
use Zenstruck\Foundry\Test\ResetDatabase;
class UserResourceTest extends ApiTestCase
{
use ResetDatabase;
... lines 10 - 14
}

We don't need HasBrowser because that's already done in the base class.

Start with public function testPostToCreateUser():

... lines 1 - 6
class UserResourceTest extends ApiTestCase
{
... lines 9 - 10
public function testPostToCreateUser(): void
{
}
}

Make a ->post() request to /api/users, toss in some json with email and password, and assertStatus(201).

And now that we've created the new user, let's jump right in and test if we can log in with their credentials! Make another ->post() request to /login, also pass some json - copy the email and password from above - then assertSuccessful():

... lines 1 - 6
class UserResourceTest extends ApiTestCase
{
... lines 9 - 10
public function testPostToCreateUser(): void
{
$this->browser()
->post('/api/users', [
'json' => [
'email' => 'draggin_in_the_morning@coffee.com',
'username' => 'draggin_in_the_morning',
'password' => 'password',
]
])
->assertStatus(201)
->post('/login', [
'json' => [
'email' => 'draggin_in_the_morning@coffee.com',
'password' => 'password',
]
])
->assertSuccessful()
;
}
}

Let's give this a go: symfony php bin/phpunit and run the entire tests/Functional/UserResourceTest.php file:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

And... ok! A 422 status code, but 201 expected. Let's see: this means something went wrong creating the user. Let's pop open the last response. Ah! My bad: I forgot to pass the required username field: we're failing validation!

Pass username... set to anything:

... lines 1 - 6
class UserResourceTest extends ApiTestCase
{
... lines 9 - 10
public function testPostToCreateUser(): void
{
$this->browser()
->post('/api/users', [
'json' => [
... line 16
'username' => 'draggin_in_the_morning',
... line 18
]
])
... lines 21 - 28
;
}
}

Try that again:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

That's what I wanted:

Expected successful status code, but got 401.

So the failure is down here. We were able to create the user... but when we tried to log in, it failed. If you were with us for episode one, you might remember why! We never set up our API to hash the password.

Check it out: inside User, we did make password part of our API. The user sends the plain-text password they want... then we're saving that directly into the database. That's a huge security problem... and it makes it impossible to log in as this user, because Symfony expects the password property to hold a hashed password.

Setting up the plainPassword Field

So our goal is clear: allow the user to send a plain password, but then hash it before it's stored in the database. To do this, instead of temporarily storing the plain-text password on the password property, let's create a totally new property: private ?string $plainPassword = null:

... lines 1 - 66
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 69 - 92
private ?string $plainPassword = null;
... lines 94 - 290
}

This will not be stored in the database: it's just a temporary spot to hold the plain password before we hash it and set that on the real password property.

Down at the bottom, I'll go to "Code"->"Generate", or Command+N on a Mac, and generate a "Getter and setter" for this. Let's clean this up a bit: accept only a string, and the PHPDoc is redundant:

... lines 1 - 66
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 69 - 279
public function setPlainPassword(string $plainPassword): User
{
$this->plainPassword = $plainPassword;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
}

Next, scroll all the way to the top and find password. Remove this from our API entirely:

... lines 1 - 67
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 70 - 86
/**
* @var string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
... lines 92 - 292
}

Instead, expose plainPassword... but use SerializedName so it's called password:

... lines 1 - 67
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 70 - 92
#[Groups(['user:write'])]
#[SerializedName('password')]
private ?string $plainPassword = null;
... lines 96 - 292
}

So we're obviously not done yet... and if you run the tests:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

Things are worse! A 500 error because of a not null violation. We're sending password, that's stored on plainPassword... then we're doing absolutely nothing with it. So the real password property stays null and explodes when it hits the database.

So here's the million-dollar question: how can we hash the plainPassword property? Or, in simpler terms, how can we run code in API Platform after the data is deserialized but before it's saved to the database? The answer is: state processors. Let's dive into this powerful concept next.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0
    }
}
userVoice