diff --git a/src/Controller/RegisterController.php b/src/Controller/RegisterController.php new file mode 100644 index 0000000..e21d446 --- /dev/null +++ b/src/Controller/RegisterController.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/src/Dto/RegisterRequest.php b/src/Dto/RegisterRequest.php new file mode 100644 index 0000000..c8bd9ec --- /dev/null +++ b/src/Dto/RegisterRequest.php @@ -0,0 +1,27 @@ +email = $email; + $this->password = $password; + } +} diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php new file mode 100644 index 0000000..c9b5516 --- /dev/null +++ b/tests/Entity/UserTest.php @@ -0,0 +1,96 @@ +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]); + } +} diff --git a/tests/Functional/RegisterApiTest.php b/tests/Functional/RegisterApiTest.php new file mode 100644 index 0000000..dcc399d --- /dev/null +++ b/tests/Functional/RegisterApiTest.php @@ -0,0 +1,312 @@ +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.', + ); + } +} diff --git a/tests/Security/LogoutSubscriberTest.php b/tests/Security/LogoutSubscriberTest.php new file mode 100644 index 0000000..094a1d7 --- /dev/null +++ b/tests/Security/LogoutSubscriberTest.php @@ -0,0 +1,34 @@ +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()); + } +}