- Fix OAuthController: add missing second parameter to redirect() - Fix KeycloakAuthenticator: get email from toArray() instead of getEmail() - Fix KeycloakAuthenticator: type-hint session for getFlashBag() access - Fix import order in KeycloakAuthenticatorTest Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
9.2 KiB
PHP
248 lines
9.2 KiB
PHP
<?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 PHPUnit\Framework\TestCase;
|
|
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner;
|
|
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;
|
|
}
|
|
}
|