271 lines
10 KiB
PHP
271 lines
10 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();
|
|
|
|
// Persist a user with a properly hashed password before each test.
|
|
// Reuse the direct-factory pattern from UserPersistenceTest — avoids
|
|
// the container chicken-and-egg during Kernel compile, and cost:4
|
|
// keeps the test suite fast.
|
|
$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();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 2: json_login happy path — valid credentials yield a session cookie
|
|
// -------------------------------------------------------------------------
|
|
|
|
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.',
|
|
);
|
|
|
|
// --- Login response body contract ---
|
|
// Capture before the follow-up /api/me request replaces the client's response.
|
|
$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.',
|
|
);
|
|
|
|
// Password hash must NEVER appear in the login response.
|
|
$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).',
|
|
);
|
|
|
|
// Exact shape: only 'id' and 'email' — no extra fields (kills 'message'/'path' junk keys).
|
|
$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.',
|
|
);
|
|
|
|
// The cookie issued must actually authenticate — not just exist.
|
|
// Same client carries the cookie jar; no re-login needed.
|
|
$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.',
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 3: json_login sad path — wrong password yields 401, no session
|
|
// -------------------------------------------------------------------------
|
|
|
|
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.',
|
|
);
|
|
|
|
// A failed login must NOT leave the client holding an authenticating session.
|
|
// We assert this at the causal level: the browser cookie jar must be empty
|
|
// after a failed login attempt on a fresh client. If a buggy implementation
|
|
// issued a real session cookie on bad credentials, the jar would be non-empty
|
|
// and this assertion would catch it — something the /api/me 401 check alone
|
|
// cannot do (a 401 there could be explained by other reasons even with a
|
|
// session cookie present, e.g. a cookie that exists but is invalid).
|
|
$cookies = $this->client->getCookieJar()->all();
|
|
|
|
self::assertEmpty(
|
|
$cookies,
|
|
'A failed login must not set any cookies — the browser cookie jar must remain empty.',
|
|
);
|
|
|
|
// Belt-and-suspenders: even if the jar were somehow populated, /api/me
|
|
// must still refuse. This check is now meaningful because it follows the
|
|
// causal assertion above rather than standing alone.
|
|
$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.',
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 5: /api/me is behind the firewall — unauthenticated access → 401
|
|
// -------------------------------------------------------------------------
|
|
|
|
public function testItReturns401OnMeWhenNotAuthenticated(): void
|
|
{
|
|
// Fresh client — no login, no cookie.
|
|
$this->client->jsonRequest('GET', '/api/me');
|
|
|
|
self::assertSame(
|
|
401,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'/api/me must return 401 when no session is present.',
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 6: authenticated /api/me returns identity (no password hash);
|
|
// logout then invalidates the session
|
|
// -------------------------------------------------------------------------
|
|
|
|
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.',
|
|
);
|
|
|
|
// Password hash must NEVER appear in the response — check all values recursively.
|
|
$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).',
|
|
);
|
|
|
|
// --- Step 3: logout ---
|
|
$this->client->jsonRequest('POST', '/api/logout');
|
|
|
|
// Capture the logout response immediately — before the next request overwrites it.
|
|
$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.',
|
|
);
|
|
|
|
// --- Step 4: /api/me must be 401 again ---
|
|
$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.',
|
|
);
|
|
}
|
|
}
|