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 SubscribeTenemos un bonito DragonTreasureResourceTest
, así que vamos a hacer un bootstrap para Usuario.
Crea una nueva clase PHP llamada, qué tal, UserResourceTest
. Haz que extienda nuestra clase personalizada ApiTestCase
, entonces sólo necesitamos use ResetDatabase
:
... lines 1 - 2 | |
namespace App\Tests\Functional; | |
use Zenstruck\Foundry\Test\ResetDatabase; | |
class UserResourceTest extends ApiTestCase | |
{ | |
use ResetDatabase; | |
... lines 10 - 14 | |
} |
No necesitamos HasBrowser
porque eso ya está hecho en la clase base.
Empieza con public function testPostToCreateUser()
:
... lines 1 - 6 | |
class UserResourceTest extends ApiTestCase | |
{ | |
... lines 9 - 10 | |
public function testPostToCreateUser(): void | |
{ | |
} | |
} |
Haz una petición ->post()
a /api/users
, añade algo de json
con email
ypassword
, y assertStatus(201)
.
Y ahora que hemos creado el nuevo usuario, ¡vamos a probar si podemos iniciar sesión con sus credenciales! Haz otra petición ->post()
a/login
, pasa también algo de json
- copia los email
y password
de arriba - y luego 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() | |
; | |
} | |
} |
Vamos a probar: symfony php bin/phpunit
y ejecuta todo el archivotests/Functional/UserResourceTest.php
:
symfony php bin/phpunit tests/Functional/UserResourceTest.php
Y... ¡ok! Un código de estado 422, pero 201 esperado. Veamos: esto significa que algo ha ido mal al crear el usuario. Abramos la última respuesta. ¡Ah! Culpa mía: olvidé pasar el campo obligatorio username
: ¡estamos fallando en la validación!
Pasa username
... puesto a cualquier cosa:
... 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 | |
; | |
} | |
} |
Inténtalo de nuevo:
symfony php bin/phpunit tests/Functional/UserResourceTest.php
Esto es lo que quería:
Esperaba un código de estado correcto, pero obtuve 401.
Así que el fallo está aquí abajo. Pudimos crear el usuario... pero cuando intentamos iniciar sesión, falló. Si estuviste con nosotros en el episodio uno, ¡tal vez recuerdes por qué! Nunca configuramos nuestra API para hacer hash de la contraseña.
Compruébalo: dentro de User
, sí hicimos que password
formara parte de nuestra API. El usuario envía la contraseña en texto plano que desea... y nosotros la guardamos directamente en la base de datos. Eso es un gran problema de seguridad... y hace imposible iniciar sesión como este usuario, porque Symfony espera que la propiedad password
contenga una contraseña con hash.
Así que nuestro objetivo está claro: permitir al usuario enviar una contraseña sin formato, pero luego hashearla antes de almacenarla en la base de datos. Para ello, en lugar de almacenar temporalmente la contraseña en texto plano en la propiedad password
, vamos a crear una propiedad totalmente nueva: private ?string $plainPassword = null
:
... lines 1 - 66 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 69 - 92 | |
private ?string $plainPassword = null; | |
... lines 94 - 290 | |
} |
Ésta no se almacenará en la base de datos: es sólo un lugar temporal para guardar la contraseña en texto plano antes de que le apliquemos el hash y la establezcamos en la propiedad real password
.
Abajo del todo, iré a "Código"->"Generar", o Command
+N
en un Mac, y generaré un "Getter y setter" para esto. Limpiemos esto un poco: acepta sólo una cadena, y el PHPDoc es redundante:
... 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; | |
} | |
} |
A continuación, desplázate hasta la parte superior y encuentra password
. Elimínalo por completo de nuestra API:
... 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 | |
} |
En su lugar, expone plainPassword
... pero utiliza SerializedName
para que se llamepassword
:
... lines 1 - 67 | |
class User implements UserInterface, PasswordAuthenticatedUserInterface | |
{ | |
... lines 70 - 92 | |
'user:write']) ([ | |
'password') ( | |
private ?string $plainPassword = null; | |
... lines 96 - 292 | |
} |
Obviamente, aún no hemos terminado... y si ejecutas las pruebas:
symfony php bin/phpunit tests/Functional/UserResourceTest.php
¡Las cosas van peor! Un error 500 debido a una violación de no nulo. Estamos enviando password
, que se almacena en plainPassword
... y luego no hacemos absolutamente nada con él. Así que la propiedad real password
permanece nula y explota cuando llega a la base de datos.
Así que aquí está la pregunta del millón: ¿cómo podemos hacer hash de la propiedad plainPassword
? O, en términos más sencillos, ¿cómo podemos ejecutar código en API Platform después de que los datos se deserialicen pero antes de que se guarden en la base de datos? La respuesta es: procesadores de estado. Vamos a sumergirnos en este potente concepto a continuación.
"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
}
}