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