buckets/tests/Functional/AuthenticationTest.php

262 lines
8.6 KiB
PHP

<?php
namespace App\Tests\Functional;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
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 AuthenticationTest extends WebTestCase
{
private const EMAIL = 'auth-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = static::createClient();
$factory = new PasswordHasherFactory([
User::class => new NativePasswordHasher(cost: 4),
]);
$hasher = new UserPasswordHasher($factory);
$user = new User();
$user->setEmail(self::EMAIL);
$user->setPassword($hasher->hashPassword($user, self::PASSWORD));
/** @var EntityManagerInterface $em */
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->persist($user);
$em->flush();
}
public function testItLogsInWithValidCredentialsAndSetsASessionCookie(): void
{
$this->client->jsonRequest('POST', '/api/login', [
'username' => self::EMAIL,
'password' => self::PASSWORD,
]);
$loginResponse = $this->client->getResponse();
self::assertLessThan(
300,
$loginResponse->getStatusCode(),
'A successful login must return a 2xx response.',
);
$setCookieHeader = $loginResponse->headers->get('Set-Cookie');
self::assertNotNull(
$setCookieHeader,
'A successful login must set a Set-Cookie header.',
);
self::assertMatchesRegularExpression(
'/HttpOnly/i',
$setCookieHeader,
'The session cookie must be HttpOnly.',
);
self::assertMatchesRegularExpression(
'/SameSite=Lax/i',
$setCookieHeader,
'The session cookie must carry SameSite=Lax.',
);
$loginBody = json_decode((string) $loginResponse->getContent(), true);
self::assertIsArray($loginBody, 'The login response must be a JSON object.');
self::assertArrayHasKey('email', $loginBody, 'The login response must include the email field.');
self::assertSame(self::EMAIL, $loginBody['email'], 'The login response must return the authenticated user\'s email.');
self::assertArrayHasKey('id', $loginBody, 'The login 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) $loginBody['id'],
'The login response id must be a valid UUIDv7.',
);
$loginResponseText = (string) $loginResponse->getContent();
self::assertStringNotContainsString(
'$2',
$loginResponseText,
'The login response must never expose a bcrypt hash (starts with $2).',
);
self::assertStringNotContainsString(
'$argon',
$loginResponseText,
'The login response must never expose an Argon2 hash (starts with $argon).',
);
$keys = array_keys($loginBody);
sort($keys);
self::assertSame(
['email', 'id'],
$keys,
'The login response body must contain EXACTLY the keys [email, id] — no more, no less.',
);
$this->client->jsonRequest('GET', '/api/me');
self::assertSame(
200,
$this->client->getResponse()->getStatusCode(),
'The session cookie set by login must authenticate a follow-up request to /api/me.',
);
}
public function testItRejectsLoginWithInvalidCredentials(): void
{
$this->client->jsonRequest('POST', '/api/login', [
'username' => self::EMAIL,
'password' => 'wrong-password',
]);
$response = $this->client->getResponse();
self::assertSame(
401,
$response->getStatusCode(),
'An invalid password must return 401 Unauthorized.',
);
$cookies = $this->client->getCookieJar()->all();
self::assertEmpty(
$cookies,
'A failed login must not set any cookies — the browser cookie jar must remain empty.',
);
$this->client->jsonRequest('GET', '/api/me');
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'After a failed login the client must not hold an authenticated session.',
);
}
public function testItRejectsLoginWithNonJsonContentType(): void
{
$this->client->request(
'POST',
'/api/login',
['username' => self::EMAIL, 'password' => self::PASSWORD],
);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'A non-JSON Content-Type on /api/login must not authenticate — the request must be rejected with 401.',
);
$cookies = $this->client->getCookieJar()->all();
self::assertEmpty(
$cookies,
'A rejected non-JSON login must not set any cookies — the browser cookie jar must remain empty.',
);
}
public function testItReturns401OnMeWhenNotAuthenticated(): void
{
$this->client->jsonRequest('GET', '/api/me');
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'/api/me must return 401 when no session is present.',
);
}
public function testItReturnsCurrentUserOnMeAndLogoutClearsTheSession(): void
{
// --- Step 1: log in ---
$this->client->jsonRequest('POST', '/api/login', [
'username' => self::EMAIL,
'password' => self::PASSWORD,
]);
self::assertLessThan(
300,
$this->client->getResponse()->getStatusCode(),
'Login must succeed before testing /api/me.',
);
// --- Step 2: GET /api/me while authenticated ---
$this->client->jsonRequest('GET', '/api/me');
$meResponse = $this->client->getResponse();
self::assertSame(200, $meResponse->getStatusCode(), '/api/me must return 200 for an authenticated user.');
$body = json_decode((string) $meResponse->getContent(), true);
self::assertIsArray($body, '/api/me must return a JSON object.');
self::assertArrayHasKey('email', $body, '/api/me response must include the email field.');
self::assertSame(self::EMAIL, $body['email'], '/api/me must return the authenticated user\'s email.');
self::assertArrayHasKey('id', $body, '/api/me 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'],
'/api/me id must be a valid UUIDv7.',
);
$responseText = (string) $meResponse->getContent();
self::assertStringNotContainsString(
'$2',
$responseText,
'/api/me must never expose a bcrypt hash (starts with $2).',
);
self::assertStringNotContainsString(
'$argon',
$responseText,
'/api/me must never expose an Argon2 hash (starts with $argon).',
);
$this->client->jsonRequest('POST', '/api/logout');
$logoutResponse = $this->client->getResponse();
self::assertSame(
204,
$logoutResponse->getStatusCode(),
'POST /api/logout must return 204 No Content — not a redirect.',
);
self::assertSame(
'',
$logoutResponse->getContent(),
'A 204 response must have an empty body.',
);
$this->client->jsonRequest('GET', '/api/me');
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'/api/me must return 401 after logout — the session must be invalidated.',
);
}
public function testItReturnsNoContentWhenLoggingOutWithoutASession(): void
{
$this->client->jsonRequest('POST', '/api/logout');
self::assertSame(
204,
$this->client->getResponse()->getStatusCode(),
'POST /api/logout without an active session must still return 204 — logout is idempotent.',
);
}
}