buckets/tests/Functional/RegisterApiTest.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.',
);
}
}