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.', ); } }