52 - Add POST /api/register endpoint with validation

This commit is contained in:
myrmidex 2026-06-28 13:29:36 +02:00
parent f76c7b9ecd
commit 5777946faf
5 changed files with 522 additions and 0 deletions

View file

@ -0,0 +1,53 @@
<?php
namespace App\Controller;
use App\Dto\RegisterRequest;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RegisterController extends AbstractController
{
public function __construct(
private UserPasswordHasherInterface $passwordHasher,
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator,
) {
}
#[Route('/api/register', name: 'api_register', methods: ['POST'])]
public function register(Request $request): JsonResponse
{
$content = $request->toArray();
$requestDto = new RegisterRequest($content['email'] ?? '', $content['password'] ?? '');
$violations = $this->validator->validate($requestDto);
if (\count($violations) > 0) {
$errors = [];
foreach ($violations as $violation) {
$errors[$violation->getPropertyPath()][] = $violation->getMessage();
}
return $this->json(['errors' => $errors], 422);
}
$user = new User();
$user->setEmail($requestDto->email);
$user->setPassword($this->passwordHasher->hashPassword($user, $requestDto->password));
$this->entityManager->persist($user);
$this->entityManager->flush();
return $this->json([
'id' => (string) $user->getId(),
'email' => $user->getEmail(),
], 201);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Dto;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(entityClass: User::class, fields: ['email'], message: 'This email is already registered.')]
class RegisterRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\Length(min: 8)]
#[Assert\NotBlank]
public string $password;
public function __construct(
string $email,
string $password,
) {
$this->email = $email;
$this->password = $password;
}
}

96
tests/Entity/UserTest.php Normal file
View file

@ -0,0 +1,96 @@
<?php
namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\UuidV7;
final class UserTest extends TestCase
{
public function testGetIdReturnsAUuidV7AssignedAtConstruction(): void
{
$user = new User();
$this->assertInstanceOf(UuidV7::class, $user->getId());
}
public function testGetRolesAlwaysIncludesRoleUserEvenWhenNoRolesAreSet(): void
{
$user = new User();
$this->assertContains('ROLE_USER', $user->getRoles());
}
public function testGetRolesMergesExplicitlySetRolesWithRoleUser(): void
{
$user = new User();
$user->setRoles(['ROLE_ADMIN']);
$roles = $user->getRoles();
$this->assertContains('ROLE_ADMIN', $roles);
$this->assertContains('ROLE_USER', $roles);
}
public function testGetRolesDeduplicatesRoleUserWhenExplicitlySet(): void
{
$user = new User();
$user->setRoles(['ROLE_USER']);
$roles = $user->getRoles();
$this->assertSame(['ROLE_USER'], array_values(array_unique($roles)));
$this->assertCount(1, array_keys($roles, 'ROLE_USER'));
}
public function testGetUserIdentifierReturnsTheEmail(): void
{
$user = new User();
$user->setEmail('alice@example.com');
$this->assertSame('alice@example.com', $user->getUserIdentifier());
}
public function testEmailGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setEmail('bob@example.com');
$this->assertSame('bob@example.com', $user->getEmail());
$this->assertSame($user, $returned, 'setEmail should return $this for fluent chaining.');
}
public function testRolesGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setRoles(['ROLE_ADMIN', 'ROLE_USER']);
$this->assertContains('ROLE_ADMIN', $user->getRoles());
$this->assertSame($user, $returned, 'setRoles should return $this for fluent chaining.');
}
public function testPasswordGetterAndSetterRoundTrip(): void
{
$user = new User();
$returned = $user->setPassword('hashed-password');
$this->assertSame('hashed-password', $user->getPassword());
$this->assertSame($user, $returned, 'setPassword should return $this for fluent chaining.');
}
public function testSerializeReplacesThePasswordWithItsCrc32cHashAndDropsTheRawValue(): void
{
$user = new User();
$user->setEmail('alice@example.com');
$user->setPassword('raw-hashed-password');
$serialized = $user->__serialize();
$key = "\0".User::class."\0password";
$this->assertArrayHasKey($key, $serialized);
$this->assertSame(hash('crc32c', 'raw-hashed-password'), $serialized[$key]);
$this->assertNotSame('raw-hashed-password', $serialized[$key]);
}
}

View file

@ -0,0 +1,312 @@
<?php
namespace App\Tests\Functional;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
final class RegisterApiTest extends WebTestCase
{
private const PASSWORD = 'correct-horse-battery-staple';
public function testItCreatesAUserAndReturns201WithExactlyIdAndEmail(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'new-user@example.com',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'A successful registration must return 201 Created.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The register response must be a JSON object.');
self::assertArrayHasKey('email', $body, 'The register response must include the email field.');
self::assertSame('new-user@example.com', $body['email'], 'The register response must return the registered email.');
self::assertArrayHasKey('id', $body, 'The register response must include the id (uuid) field.');
self::assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
(string) $body['id'],
'The register response id must be a valid UUIDv7.',
);
$keys = array_keys($body);
sort($keys);
self::assertSame(
['email', 'id'],
$keys,
'The register response body must contain EXACTLY the keys [email, id] — no more, no less.',
);
$responseText = (string) $response->getContent();
self::assertStringNotContainsString(
'$2',
$responseText,
'The register response must never expose a bcrypt hash (starts with $2).',
);
self::assertStringNotContainsString(
'$argon',
$responseText,
'The register response must never expose an Argon2 hash (starts with $argon).',
);
}
public function testItPersistsAHashedPasswordThatAuthenticatesViaLogin(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'login-after-register@example.com',
'password' => self::PASSWORD,
]);
self::assertSame(
201,
$client->getResponse()->getStatusCode(),
'Registration must succeed before testing login.',
);
$client->jsonRequest('POST', '/api/login', [
'username' => 'login-after-register@example.com',
'password' => self::PASSWORD,
]);
$loginResponse = $client->getResponse();
self::assertLessThan(
300,
$loginResponse->getStatusCode(),
'Logging in with the just-registered credentials must succeed — proving the password was hashed, not stored plaintext.',
);
self::assertNotNull(
$loginResponse->headers->get('Set-Cookie'),
'A successful login after registration must set a session cookie.',
);
}
public function testItRejectsADuplicateEmailWith422(): void
{
$client = static::createClient();
$factory = new PasswordHasherFactory([
User::class => new NativePasswordHasher(cost: 4),
]);
$hasher = new UserPasswordHasher($factory);
$existing = new User();
$existing->setEmail('already-registered@example.com');
$existing->setPassword($hasher->hashPassword($existing, 'some-other-password'));
/** @var EntityManagerInterface $em */
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->persist($existing);
$em->flush();
$client->jsonRequest('POST', '/api/register', [
'email' => 'already-registered@example.com',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'Registering an already-used email must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey(
'email',
$body['errors'],
'The error response must name the "email" field as the source of the violation.',
);
}
public function testItRejectsMissingOrBlankFieldsWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => '',
'password' => '',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'Blank email and password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'Blank email must produce a violation named "email".');
self::assertArrayHasKey('password', $body['errors'], 'Blank password must produce a violation named "password".');
}
public function testItRejectsAnInvalidEmailFormatWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'not-an-email',
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'An invalid email format must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'An invalid email format must produce a violation named "email".');
}
public function testItRejectsATooShortPasswordWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'short-password@example.com',
'password' => 'short',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A too-short password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'A too-short password must produce a violation named "password".');
}
public function testItRejectsAnAbsentEmailFieldWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'password' => self::PASSWORD,
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A request body that omits the "email" key entirely must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('email', $body['errors'], 'An absent email key must produce a violation named "email".');
}
public function testItRejectsAnAbsentPasswordFieldWith422(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'absent-password@example.com',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A request body that omits the "password" key entirely must return 422 Unprocessable Entity, not 500.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'An absent password key must produce a violation named "password".');
}
public function testItReturnsAllViolationMessagesForAFieldAsAnArray(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'multi-violation@example.com',
'password' => '',
]);
$response = $client->getResponse();
self::assertSame(
422,
$response->getStatusCode(),
'A blank password must return 422 Unprocessable Entity.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The error response must be a JSON object.');
self::assertArrayHasKey('errors', $body, 'The error response must have an "errors" key.');
self::assertArrayHasKey('password', $body['errors'], 'A blank password must produce a violation named "password".');
self::assertIsArray(
$body['errors']['password'],
'Each field in "errors" must map to an ARRAY of messages, not a single string — a blank password triggers both NotBlank and Length violations.',
);
self::assertGreaterThan(
1,
\count($body['errors']['password']),
'A blank password violates both NotBlank and Length(min: 8) — both messages must survive, not just the last one written.',
);
}
public function testItIsReachableWithoutAuthentication(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/register', [
'email' => 'anon-reachable@example.com',
'password' => self::PASSWORD,
]);
self::assertNotSame(
401,
$client->getResponse()->getStatusCode(),
'/api/register must be publicly reachable — an unauthenticated request must not be blocked with 401 by access_control.',
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Tests\Security;
use App\Security\LogoutSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final class LogoutSubscriberTest extends TestCase
{
public function testGetSubscribedEventsMapsLogoutEventToOnLogout(): void
{
$this->assertSame(
[LogoutEvent::class => 'onLogout'],
LogoutSubscriber::getSubscribedEvents(),
);
}
public function testOnLogoutSetsAnEmptyNoContentResponseOnTheEvent(): void
{
$event = new LogoutEvent(new Request(), null);
$subscriber = new LogoutSubscriber();
$subscriber->onLogout($event);
$response = $event->getResponse();
$this->assertInstanceOf(Response::class, $response);
$this->assertSame(Response::HTTP_NO_CONTENT, $response->getStatusCode());
$this->assertSame('', $response->getContent());
}
}