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:
25
tests/Controller/OAuthControllerTest.php
Normal file
25
tests/Controller/OAuthControllerTest.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
247
tests/Security/KeycloakAuthenticatorTest.php
Normal file
247
tests/Security/KeycloakAuthenticatorTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user