fix: stop MyE&P from granting roles and matching the wrong account
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\BusProNet\UserDataHandler;
|
||||
use App\Entity\User;
|
||||
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
|
||||
use App\Security\MyEpAuthenticator;
|
||||
use App\Security\OAuth2\MyEpClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use GuzzleHttp\Psr7\Request as Psr7Request;
|
||||
use GuzzleHttp\Psr7\Response as Psr7Response;
|
||||
use League\OAuth2\Client\Provider\AbstractProvider;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
class MyEpAuthenticatorTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private ObjectRepository&MockObject $repository;
|
||||
|
||||
/** @var User[] */
|
||||
private array $persisted = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->persisted = [];
|
||||
$this->repository = $this->createMock(ObjectRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->entityManager->method('getRepository')->willReturn($this->repository);
|
||||
$this->entityManager
|
||||
->method('persist')
|
||||
->willReturnCallback(function (object $entity): void {
|
||||
if ($entity instanceof User) {
|
||||
$this->persisted[] = $entity;
|
||||
}
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The core of this test case: MyE&P may report an administrative role, but it may
|
||||
* not grant one. Only a super admin turns the marker into the real role.
|
||||
*/
|
||||
public function testAdministrativeRolesAreImportedAsPendingMarkersOnly(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$user = $this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']));
|
||||
|
||||
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
$this->assertNotContains('ROLE_ADMIN', $user->getRoles());
|
||||
$this->assertContains('ROLE_TEAMER', $user->getRoles());
|
||||
}
|
||||
|
||||
public function testUnknownRolesAreNeverStored(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
// ROLE_ADMINISTRATIVE is granted by the role hierarchy and must not be settable
|
||||
// from the outside, ROLE_KUNDE means nothing here
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER', 'ROLE_ADMINISTRATIVE', 'ROLE_KUNDE']);
|
||||
|
||||
$user = $this->loadUser($userinfo);
|
||||
|
||||
$this->assertSame(['ROLE_USER', 'ROLE_TEAMER'], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testLoginIsRefusedWithoutAnEligibleRole(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||
|
||||
try {
|
||||
$this->loadUser($this->createUserinfo(['ROLE_KUNDE']));
|
||||
} finally {
|
||||
$this->assertSame([], $this->persisted, 'no account may be created');
|
||||
}
|
||||
}
|
||||
|
||||
public function testLoginIsRefusedWithoutAnyRoleClaim(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$userinfo = $this->createUserinfo([]);
|
||||
unset($userinfo['roles']);
|
||||
|
||||
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||
|
||||
$this->loadUser($userinfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* A super admin's manual demotion has to survive the user's next SSO login.
|
||||
*/
|
||||
public function testExistingGrantedRolesAreNeverOverwritten(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||
$this->repository->method('findOneBy')->willReturn($user);
|
||||
|
||||
$this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_MANAGER', 'ROLE_TEAMER']));
|
||||
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
$this->assertSame(
|
||||
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
|
||||
$user->getPendingRoles(),
|
||||
);
|
||||
$this->assertNotContains('ROLE_ADMIN', $user->getRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* An already approved role must not be demoted back to a marker on the next login.
|
||||
*/
|
||||
public function testAnAlreadyGrantedAdministrativeRoleIsKept(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']);
|
||||
$this->repository->method('findOneBy')->willReturn($user);
|
||||
|
||||
$this->loadUser($this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']));
|
||||
|
||||
$this->assertSame(['ROLE_ADMIN', 'ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
$this->assertSame([], $user->getPendingRoles());
|
||||
}
|
||||
|
||||
public function testTeamerRoleIsGrantedToAnExistingUser(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles([User::PENDING_ROLES['ROLE_MANAGER']]);
|
||||
$this->repository->method('findOneBy')->willReturn($user);
|
||||
|
||||
$this->loadUser($this->createUserinfo(['ROLE_TEAMER', 'ROLE_MANAGER']));
|
||||
|
||||
$this->assertContains('ROLE_TEAMER', $user->getAssignedRoles());
|
||||
$this->assertSame([User::PENDING_ROLES['ROLE_MANAGER']], $user->getPendingRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* MyE&P must not be able to undo a deletion, in any direction.
|
||||
*/
|
||||
public function testDeletedUserIsReturnedWithoutAnySync(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||
$user->setHotelCodes(['OLD']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->repository->method('findOneBy')->willReturn($user);
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_ADMIN', 'ROLE_TEAMER']);
|
||||
$userinfo['profile']['hotel_codes'] = ['NEW'];
|
||||
|
||||
// returned rather than refused, so the UserChecker can explain the deletion
|
||||
$this->assertSame($user, $this->loadUser($userinfo));
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
$this->assertSame([], $user->getPendingRoles());
|
||||
$this->assertSame(['OLD'], $user->getHotelCodes());
|
||||
$this->assertNull($user->getLastLoginAt());
|
||||
}
|
||||
|
||||
public function testNewUserIsCreatedWithBothBusProIds(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$user = $this->loadUser($this->createUserinfo(['ROLE_TEAMER']));
|
||||
|
||||
$this->assertSame(6789, $user->getBusProAddressId());
|
||||
$this->assertSame(12345, $user->getBusProPersonId());
|
||||
$this->assertSame('[email protected]', $user->getEmail());
|
||||
$this->assertSame(['HOTEL'], $user->getHotelCodes());
|
||||
$this->assertNotNull($user->getTeamer());
|
||||
}
|
||||
|
||||
public function testNoAccountIsCreatedWithoutTheBusProIdClaims(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||
unset($userinfo['person_id'], $userinfo['address_id']);
|
||||
|
||||
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||
|
||||
try {
|
||||
$this->loadUser($userinfo);
|
||||
} finally {
|
||||
$this->assertSame([], $this->persisted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a BusPro login created is matched on the id pair, so the two login
|
||||
* paths cannot end up with two rows for the same person.
|
||||
*/
|
||||
public function testExistingUserIsMatchedOnTheBusProIdsRatherThanTheEmail(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->repository
|
||||
->expects($this->once())
|
||||
->method('findOneBy')
|
||||
->with(['busProAddressId' => 6789, 'busProPersonId' => 12345])
|
||||
->willReturn($user)
|
||||
;
|
||||
$this->repository->expects($this->never())->method('findBy');
|
||||
|
||||
$this->assertSame($user, $this->loadUser($this->createUserinfo(['ROLE_TEAMER'])));
|
||||
$this->assertSame([], $this->persisted);
|
||||
}
|
||||
|
||||
public function testUserMatchedByEmailHasItsBusProIdsBackfilled(): void
|
||||
{
|
||||
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([$user]);
|
||||
|
||||
$this->loadUser($this->createUserinfo(['ROLE_TEAMER']));
|
||||
|
||||
$this->assertSame(6789, $user->getBusProAddressId());
|
||||
$this->assertSame(12345, $user->getBusProPersonId());
|
||||
}
|
||||
|
||||
public function testAmbiguousEmailNeverCreatesOrPicksAnAccount(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([(new User())->setEmail('[email protected]'), (new User())->setEmail('[email protected]')]);
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||
unset($userinfo['person_id'], $userinfo['address_id']);
|
||||
|
||||
$this->expectException(CustomUserMessageAuthenticationException::class);
|
||||
|
||||
try {
|
||||
$this->loadUser($userinfo);
|
||||
} finally {
|
||||
$this->assertSame([], $this->persisted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial payload must refuse the login at worst, never fatal.
|
||||
*/
|
||||
public function testPartialProfileDoesNotFail(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||
unset($userinfo['profile']);
|
||||
|
||||
$user = $this->loadUser($userinfo);
|
||||
|
||||
$this->assertSame(['ROLE_USER', 'ROLE_TEAMER'], $user->getRoles());
|
||||
$this->assertSame([], $user->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testDateOfBirthIsReadFromTheProfile(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||
$userinfo['profile']['date_of_birth'] = '1980-04-01';
|
||||
|
||||
$user = $this->loadUser($userinfo);
|
||||
|
||||
$this->assertSame('1980-04-01', $user->getTeamer()?->getDateOfBirth()?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testUnparsableDateOfBirthIsIgnoredRatherThanFatal(): void
|
||||
{
|
||||
$this->repository->method('findOneBy')->willReturn(null);
|
||||
$this->repository->method('findBy')->willReturn([]);
|
||||
|
||||
$userinfo = $this->createUserinfo(['ROLE_TEAMER']);
|
||||
$userinfo['profile']['date_of_birth'] = 'not a date';
|
||||
|
||||
$user = $this->loadUser($userinfo);
|
||||
|
||||
$this->assertNull($user->getTeamer()?->getDateOfBirth());
|
||||
}
|
||||
|
||||
public function testAuthenticatorDoesNotSupportTheRouteWhileTheFeatureIsOff(): void
|
||||
{
|
||||
$authenticator = $this->createAuthenticator([], featureActive: false);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_myep_auth_check');
|
||||
|
||||
$this->assertFalse($authenticator->supports($request));
|
||||
}
|
||||
|
||||
public function testAuthenticatorSupportsTheRouteWhileTheFeatureIsOn(): void
|
||||
{
|
||||
$authenticator = $this->createAuthenticator([]);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_myep_auth_check');
|
||||
|
||||
$this->assertTrue($authenticator->supports($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function createUserinfo(array $roles): array
|
||||
{
|
||||
return [
|
||||
'email' => '[email protected]',
|
||||
'person_id' => 12345,
|
||||
'address_id' => 6789,
|
||||
'roles' => $roles,
|
||||
'profile' => [
|
||||
'first_name' => 'Erika',
|
||||
'last_name' => 'Musterfrau',
|
||||
'hotel_codes' => ['HOTEL'],
|
||||
'communication' => [
|
||||
'email' => '[email protected]',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadUser(array $userinfo): User
|
||||
{
|
||||
$authenticator = $this->createAuthenticator($userinfo);
|
||||
|
||||
$request = new Request(query: ['code' => 'code', 'state' => 'state']);
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
|
||||
$passport = $authenticator->authenticate($request);
|
||||
|
||||
/** @var UserBadge $badge */
|
||||
$badge = $passport->getBadge(UserBadge::class);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $badge->getUser();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createAuthenticator(array $userinfo, bool $featureActive = true): MyEpAuthenticator
|
||||
{
|
||||
$httpClient = $this->createMock(ClientInterface::class);
|
||||
$httpClient
|
||||
->method('sendRequest')
|
||||
->willReturn(new Psr7Response(200, [], json_encode($userinfo)))
|
||||
;
|
||||
|
||||
$provider = $this->createMock(AbstractProvider::class);
|
||||
$provider->method('getResourceOwnerDetailsUrl')->willReturn('https://my.example.com/api/userinfo');
|
||||
$provider
|
||||
->method('getAuthenticatedRequest')
|
||||
->willReturn(new Psr7Request('GET', 'https://my.example.com/api/userinfo'))
|
||||
;
|
||||
$provider->method('getHttpClient')->willReturn($httpClient);
|
||||
|
||||
$client = $this->createMock(MyEpClient::class);
|
||||
$client->method('fetchAccessToken')->willReturn(new AccessToken(['access_token' => 'token']));
|
||||
$client->method('getProvider')->willReturn($provider);
|
||||
|
||||
$featureManager = $this->createMock(FeatureManagerInterface::class);
|
||||
$featureManager->method('isActive')->willReturn($featureActive);
|
||||
|
||||
return new MyEpAuthenticator(
|
||||
$client,
|
||||
new UserDataHandler($this->entityManager, $this->createMock(LoggerInterface::class)),
|
||||
$this->entityManager,
|
||||
$this->createMock(UrlGeneratorInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$featureManager,
|
||||
$this->createMock(RequiredTeamerCheckRegistry::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user