If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe have a pretty nice DragonTreasureResourceTest
, so let's bootstrap one for User.
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.
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 | |
'user:write']) ([ | |
'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.
"Houston: no signs of life"
Start the conversation!
// 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
}
}