252 lines
8.7 KiB
PHP
252 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Security;
|
|
|
|
use App\Entity\User;
|
|
use App\Repository\UserRepository;
|
|
use App\Security\EpAuthenticator;
|
|
use App\Security\OAuth2\AuthorizationRequestException;
|
|
use App\Security\OAuth2\EpClient;
|
|
use App\Security\OAuth2\UserinfoUnavailableException;
|
|
use App\Security\Role;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use League\OAuth2\Client\Token\AccessToken;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
|
|
|
/**
|
|
* The authenticator against a stubbed EpClient.
|
|
*
|
|
* Everything that speaks HTTP lives in EpClient, so this exercises the part that actually
|
|
* carries policy: which roles let somebody in, what identifies them, and what is written back.
|
|
*/
|
|
class EpAuthenticatorTest extends KernelTestCase
|
|
{
|
|
private EntityManagerInterface $em;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
self::bootKernel();
|
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
|
$this->em->getConnection()->beginTransaction();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->em->getConnection()->rollBack();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testAnEmployeeIsLoggedInAndCreatedLocally(): void
|
|
{
|
|
$user = $this->authenticate([
|
|
'email' => '[email protected]',
|
|
'roles' => ['ROLE_EMPLOYEE'],
|
|
'profile' => ['first_name' => 'Anna', 'last_name' => 'Berger'],
|
|
]);
|
|
|
|
self::assertSame('[email protected]', $user->getEmail());
|
|
self::assertSame('Anna Berger', $user->getName());
|
|
self::assertContains(Role::EMPLOYEE, $user->getRoles());
|
|
}
|
|
|
|
public function testAnAdministratorIsLoggedInToo(): void
|
|
{
|
|
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_ADMIN']]);
|
|
|
|
self::assertContains(Role::ADMIN, $user->getRoles());
|
|
}
|
|
|
|
public function testSomebodyWithNeitherRoleIsRefused(): void
|
|
{
|
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
|
$this->expectExceptionMessage('Mitarbeiter- oder Administrator-Rolle');
|
|
|
|
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER', 'ROLE_TEAMER']]);
|
|
}
|
|
|
|
public function testARefusedLoginCreatesNoLocalAccount(): void
|
|
{
|
|
try {
|
|
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER']]);
|
|
} catch (CustomUserMessageAuthenticationException) {
|
|
}
|
|
|
|
self::assertNull($this->em->getRepository(User::class)->findOneBy(['email' => '[email protected]']));
|
|
}
|
|
|
|
/**
|
|
* The roles claim is the identity provider's word, and myep is not the only thing that could
|
|
* ever put a value in it. Only what this application recognises is stored.
|
|
*/
|
|
public function testOnlyWhitelistedRolesAreStored(): void
|
|
{
|
|
$user = $this->authenticate([
|
|
'email' => '[email protected]',
|
|
'roles' => ['ROLE_EMPLOYEE', 'ROLE_SUPER_ADMIN', 'ROLE_HOUSE_MANAGER'],
|
|
]);
|
|
|
|
self::assertSame([Role::EMPLOYEE, 'ROLE_USER'], $user->getRoles());
|
|
}
|
|
|
|
public function testALoginWithoutAnEmailClaimIsRefused(): void
|
|
{
|
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
|
|
|
$this->authenticate(['roles' => ['ROLE_EMPLOYEE']]);
|
|
}
|
|
|
|
/**
|
|
* The consumer guide is explicit: profile.communication.email comes from the CRM address
|
|
* record and can belong to a different contact entirely. Keying on it would log somebody in
|
|
* as their colleague.
|
|
*/
|
|
public function testIdentityComesFromTheTopLevelEmailNotTheProfileOne(): void
|
|
{
|
|
$user = $this->authenticate([
|
|
'email' => '[email protected]',
|
|
'roles' => ['ROLE_EMPLOYEE'],
|
|
'profile' => [
|
|
'first_name' => 'Anna',
|
|
'last_name' => 'Berger',
|
|
'communication' => ['email' => '[email protected]'],
|
|
],
|
|
]);
|
|
|
|
self::assertSame('[email protected]', $user->getEmail());
|
|
}
|
|
|
|
public function testAnExistingAccountIsUpdatedRatherThanDuplicated(): void
|
|
{
|
|
$existing = new User('[email protected]', 'Anna Alt');
|
|
$existing->setRoles([Role::EMPLOYEE]);
|
|
$this->em->persist($existing);
|
|
$this->em->flush();
|
|
|
|
$user = $this->authenticate([
|
|
'email' => '[email protected]',
|
|
'roles' => ['ROLE_EMPLOYEE'],
|
|
'profile' => ['first_name' => 'Anna', 'last_name' => 'Neu'],
|
|
]);
|
|
|
|
self::assertSame($existing->getId(), $user->getId());
|
|
self::assertSame('Anna Neu', $user->getName());
|
|
self::assertCount(1, $this->em->getRepository(User::class)->findBy(['email' => '[email protected]']));
|
|
}
|
|
|
|
/**
|
|
* myep leads: a role it stops reporting is withdrawn here, not kept because it was granted
|
|
* once.
|
|
*/
|
|
public function testARoleMyepNoLongerReportsIsWithdrawn(): void
|
|
{
|
|
$existing = new User('[email protected]', 'Chef');
|
|
$existing->setRoles([Role::EMPLOYEE, Role::ADMIN]);
|
|
$this->em->persist($existing);
|
|
$this->em->flush();
|
|
|
|
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
|
|
|
|
self::assertNotContains(Role::ADMIN, $user->getRoles());
|
|
}
|
|
|
|
public function testTheEmailStandsInForAMissingProfileName(): void
|
|
{
|
|
// The profile scope may simply not have been granted.
|
|
$user = $this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
|
|
|
|
self::assertSame('[email protected]', $user->getName());
|
|
}
|
|
|
|
public function testAMalformedRolesClaimIsTreatedAsNoRoles(): void
|
|
{
|
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
|
|
|
$this->authenticate(['email' => '[email protected]', 'roles' => 'ROLE_EMPLOYEE']);
|
|
}
|
|
|
|
public function testAFailedTokenExchangeBecomesAFriendlyFailure(): void
|
|
{
|
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
|
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
|
|
|
|
$this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch'))
|
|
->authenticate(new Request());
|
|
}
|
|
|
|
public function testAnUnreachableUserinfoBecomesAFriendlyFailure(): void
|
|
{
|
|
$this->expectException(CustomUserMessageAuthenticationException::class);
|
|
$this->expectExceptionMessage('Anmeldung über myep fehlgeschlagen.');
|
|
|
|
$this->authenticator(throwOnUserinfo: new UserinfoUnavailableException('boom'))
|
|
->authenticate(new Request());
|
|
}
|
|
|
|
public function testItOnlyFiresOnTheCallbackRoute(): void
|
|
{
|
|
$authenticator = $this->authenticator();
|
|
|
|
$callback = new Request();
|
|
$callback->attributes->set('_route', 'app_auth_check');
|
|
self::assertTrue($authenticator->supports($callback));
|
|
|
|
$dashboard = new Request();
|
|
$dashboard->attributes->set('_route', 'app_dashboard');
|
|
self::assertFalse($authenticator->supports($dashboard));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $userinfo
|
|
*/
|
|
private function authenticate(array $userinfo): User
|
|
{
|
|
$passport = $this->authenticator($userinfo)->authenticate(new Request());
|
|
|
|
$badge = $passport->getBadge(UserBadge::class);
|
|
self::assertInstanceOf(UserBadge::class, $badge);
|
|
|
|
$user = $badge->getUser();
|
|
self::assertInstanceOf(User::class, $user);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $userinfo
|
|
*/
|
|
private function authenticator(
|
|
array $userinfo = [],
|
|
?\Throwable $throwOnToken = null,
|
|
?\Throwable $throwOnUserinfo = null,
|
|
): EpAuthenticator {
|
|
$client = $this->createStub(EpClient::class);
|
|
|
|
if (null !== $throwOnToken) {
|
|
$client->method('fetchAccessToken')->willThrowException($throwOnToken);
|
|
} else {
|
|
$client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token']));
|
|
}
|
|
|
|
if (null !== $throwOnUserinfo) {
|
|
$client->method('fetchUserinfo')->willThrowException($throwOnUserinfo);
|
|
} else {
|
|
$client->method('fetchUserinfo')->willReturn($userinfo);
|
|
}
|
|
|
|
return new EpAuthenticator(
|
|
$client,
|
|
self::getContainer()->get(UserRepository::class),
|
|
$this->em,
|
|
self::getContainer()->get(UrlGeneratorInterface::class),
|
|
new NullLogger(),
|
|
);
|
|
}
|
|
}
|