312 lines
11 KiB
PHP
312 lines
11 KiB
PHP
<?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.',
|
|
);
|
|
}
|
|
}
|