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