Add tests for KeycloakAuthenticator, OAuthController, and SSO login button

- Add KeycloakAuthenticatorTest: supports, success/failure redirects, user creation,
  email linking, user update, /superadmin group to ROLE_ROOT mapping, unknown groups
- Add OAuthControllerTest: SSO login redirects to Keycloak, SSO logout redirects to home
- Add SSO button presence test to SecurityControllerTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 10:47:39 +01:00
parent 2405fcc2da
commit 53d8b30942
3 changed files with 282 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class OAuthControllerTest extends WebTestCase
{
public function testSsoLoginRedirectsToKeycloak(): void
{
$client = static::createClient();
$client->request('GET', '/connection/sso/login');
self::assertResponseRedirects();
self::assertStringContainsString('auth.esy-web.dev', $client->getResponse()->headers->get('Location'));
}
public function testSsoLogoutRedirectsToHome(): void
{
$client = static::createClient();
$client->request('GET', '/connection/sso/logout');
self::assertResponseRedirects('/');
}
}

View File

@@ -57,6 +57,16 @@ class SecurityControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testLoginPageContainsSsoButton(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/connexion');
self::assertResponseIsSuccessful();
$ssoLink = $crawler->filter('a[href="/connection/sso/login"]');
self::assertCount(1, $ssoLink);
}
public function testLogoutThrowsLogicException(): void
{
$this->expectException(\LogicException::class);

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Tests\Security;
use App\Entity\User;
use App\Security\KeycloakAuthenticator;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Client\OAuth2ClientInterface;
use League\OAuth2\Client\Token\AccessToken;
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBag;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class KeycloakAuthenticatorTest extends TestCase
{
private ClientRegistry $clientRegistry;
private EntityManagerInterface $em;
private RouterInterface $router;
private KeycloakAuthenticator $authenticator;
protected function setUp(): void
{
$this->clientRegistry = $this->createMock(ClientRegistry::class);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->router = $this->createMock(RouterInterface::class);
$this->authenticator = new KeycloakAuthenticator(
$this->clientRegistry,
$this->em,
$this->router,
);
}
public function testSupportsReturnsTrueForCheckRoute(): void
{
$request = new Request();
$request->attributes->set('_route', 'app_oauth_keycloak_check');
self::assertTrue($this->authenticator->supports($request));
}
public function testSupportsReturnsFalseForOtherRoute(): void
{
$request = new Request();
$request->attributes->set('_route', 'app_login');
self::assertFalse($this->authenticator->supports($request));
}
public function testOnAuthenticationSuccessRedirectsToAccount(): void
{
$this->router->method('generate')
->with('app_account')
->willReturn('/mon-compte');
$request = new Request();
$token = $this->createMock(TokenInterface::class);
$response = $this->authenticator->onAuthenticationSuccess($request, $token, 'main');
self::assertSame(302, $response->getStatusCode());
self::assertSame('/mon-compte', $response->getTargetUrl());
}
public function testOnAuthenticationFailureRedirectsToLoginWithFlash(): void
{
$this->router->method('generate')
->with('app_login')
->willReturn('/connexion');
$flashBag = new FlashBag();
$session = $this->createMock(Session::class);
$session->method('getFlashBag')->willReturn($flashBag);
$request = new Request();
$request->setSession($session);
$exception = new AuthenticationException('Test error');
$response = $this->authenticator->onAuthenticationFailure($request, $exception);
self::assertSame(302, $response->getStatusCode());
self::assertSame('/connexion', $response->getTargetUrl());
self::assertSame(['Echec de la connexion SSO E-Cosplay.'], $flashBag->get('error'));
}
public function testAuthenticateCreatesNewUser(): void
{
$keycloakUser = $this->createKeycloakUser('kc-123', 'new@example.com', 'Jean', 'Dupont', []);
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturn(null);
$this->em->method('getRepository')->willReturn($repo);
$this->em->expects(self::once())->method('persist');
$this->em->expects(self::once())->method('flush');
$passport = $this->callAuthenticate($keycloakUser);
$user = $passport->getUser();
self::assertInstanceOf(User::class, $user);
self::assertSame('new@example.com', $user->getEmail());
self::assertSame('Jean', $user->getFirstName());
self::assertSame('Dupont', $user->getLastName());
self::assertSame('kc-123', $user->getKeycloakId());
}
public function testAuthenticateLinksExistingUserByEmail(): void
{
$existingUser = new User();
$existingUser->setEmail('existing@example.com');
$existingUser->setFirstName('Existing');
$existingUser->setLastName('User');
$existingUser->setPassword('$2y$13$hashed');
$keycloakUser = $this->createKeycloakUser('kc-456', 'existing@example.com', 'Existing', 'User', []);
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturnCallback(function (array $criteria) use ($existingUser) {
if (isset($criteria['keycloakId'])) {
return null;
}
return $existingUser;
});
$this->em->method('getRepository')->willReturn($repo);
$this->em->expects(self::never())->method('persist');
$this->em->expects(self::once())->method('flush');
$passport = $this->callAuthenticate($keycloakUser);
$user = $passport->getUser();
self::assertSame($existingUser, $user);
self::assertSame('kc-456', $user->getKeycloakId());
}
public function testAuthenticateUpdatesExistingKeycloakUser(): void
{
$existingUser = new User();
$existingUser->setEmail('old@example.com');
$existingUser->setFirstName('Old');
$existingUser->setLastName('Name');
$existingUser->setPassword('$2y$13$hashed');
$existingUser->setKeycloakId('kc-789');
$keycloakUser = $this->createKeycloakUser('kc-789', 'updated@example.com', 'New', 'Name', []);
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturnCallback(function (array $criteria) use ($existingUser) {
if (isset($criteria['keycloakId']) && 'kc-789' === $criteria['keycloakId']) {
return $existingUser;
}
return null;
});
$this->em->method('getRepository')->willReturn($repo);
$this->em->expects(self::once())->method('flush');
$passport = $this->callAuthenticate($keycloakUser);
$user = $passport->getUser();
self::assertSame($existingUser, $user);
self::assertSame('updated@example.com', $user->getEmail());
self::assertSame('New', $user->getFirstName());
self::assertSame('Name', $user->getLastName());
}
public function testAuthenticateMapsSuperadminGroupToRoleRoot(): void
{
$keycloakUser = $this->createKeycloakUser('kc-admin', 'admin@example.com', 'Admin', 'User', ['/superadmin']);
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturn(null);
$this->em->method('getRepository')->willReturn($repo);
$this->em->expects(self::once())->method('persist');
$passport = $this->callAuthenticate($keycloakUser);
$user = $passport->getUser();
self::assertContains('ROLE_ROOT', $user->getRoles());
}
public function testAuthenticateIgnoresUnknownGroups(): void
{
$keycloakUser = $this->createKeycloakUser('kc-normal', 'normal@example.com', 'Normal', 'User', ['/editors', '/viewers']);
$repo = $this->createMock(EntityRepository::class);
$repo->method('findOneBy')->willReturn(null);
$this->em->method('getRepository')->willReturn($repo);
$this->em->expects(self::once())->method('persist');
$passport = $this->callAuthenticate($keycloakUser);
$user = $passport->getUser();
self::assertSame(['ROLE_USER'], $user->getRoles());
}
/**
* @param list<string> $groups
*/
private function createKeycloakUser(string $id, string $email, string $firstName, string $lastName, array $groups): KeycloakResourceOwner
{
return new KeycloakResourceOwner([
'sub' => $id,
'email' => $email,
'given_name' => $firstName,
'family_name' => $lastName,
'groups' => $groups,
]);
}
private function callAuthenticate(KeycloakResourceOwner $keycloakUser): SelfValidatingPassport
{
$accessToken = $this->createMock(AccessToken::class);
$accessToken->method('getToken')->willReturn('fake-token');
$oauthClient = $this->createMock(OAuth2ClientInterface::class);
$oauthClient->method('fetchUserFromToken')->with($accessToken)->willReturn($keycloakUser);
$this->clientRegistry->method('getClient')->with('keycloak')->willReturn($oauthClient);
// Use reflection to mock fetchAccessToken from parent OAuth2Authenticator
$authenticator = $this->getMockBuilder(KeycloakAuthenticator::class)
->setConstructorArgs([$this->clientRegistry, $this->em, $this->router])
->onlyMethods(['fetchAccessToken'])
->getMock();
$authenticator->method('fetchAccessToken')->willReturn($accessToken);
$request = new Request();
$request->attributes->set('_route', 'app_oauth_keycloak_check');
$passport = $authenticator->authenticate($request);
// Resolve the UserBadge callback
$passport->getUser();
return $passport;
}
}