diff --git a/src/Security/MyEpAuthenticator.php b/src/Security/MyEpAuthenticator.php index 33406ba..60ec942 100644 --- a/src/Security/MyEpAuthenticator.php +++ b/src/Security/MyEpAuthenticator.php @@ -5,9 +5,11 @@ namespace App\Security; use App\BusProNet\UserDataHandler; use App\Entity\Teamer; use App\Entity\User; +use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry; use App\Security\OAuth2\AuthorizationRequestException; use App\Security\OAuth2\MyEpClient; use Doctrine\ORM\EntityManagerInterface; +use Flagception\Manager\FeatureManagerInterface; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use Psr\Http\Client\ClientExceptionInterface; use Psr\Log\LoggerInterface; @@ -26,22 +28,35 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait; class MyEpAuthenticator extends AbstractAuthenticator { - private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER']; - use TargetPathTrait; + /** + * The roles that entitle someone to log in here at all. Anything else MyE&P reports + * is dropped rather than stored, so that no role this application assigns a meaning + * to can be set from the outside. + */ + private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER']; + public function __construct( private readonly MyEpClient $client, private readonly UserDataHandler $userDataHandler, private readonly EntityManagerInterface $entityManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly LoggerInterface $logger, + private readonly FeatureManagerInterface $featureManager, + private readonly RequiredTeamerCheckRegistry $requiredTeamerCheckRegistry, ) { } public function supports(Request $request): ?bool { - return 'app_myep_auth_check' === $request->attributes->get('_route'); + if ('app_myep_auth_check' !== $request->attributes->get('_route')) { + return false; + } + + // the controller guards the route as well, but the authenticator must not fire + // on a disabled feature either: it is what creates and updates accounts + return $this->featureManager->isActive('myep_login'); } public function authenticate(Request $request): Passport @@ -58,30 +73,44 @@ class MyEpAuthenticator extends AbstractAuthenticator try { $provider = $this->client->getProvider(); $url = $provider->getResourceOwnerDetailsUrl($accessToken); - $request = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [ + $userinfoRequest = $provider->getAuthenticatedRequest('GET', $url, $accessToken, [ 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]); - $response = $provider->getHttpClient()->sendRequest($request); + $response = $provider->getHttpClient()->sendRequest($userinfoRequest); } catch (ClientExceptionInterface $e) { $this->logger->error('Login via MyE&P failed due to unexpected userinfo response'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } - $userinfo = json_decode($response->getBody(), true); - $username = $userinfo['email'] ?? null; - if (null === $username) { + $userinfo = json_decode($response->getBody(), true); + + if (false === is_array($userinfo)) { + $this->logger->error('Login via MyE&P failed due to unreadable userinfo payload'); + throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); + } + + if (null === ($userinfo['email'] ?? null)) { $this->logger->error('Login via MyE&P failed due to missing username claim'); throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } - if (null !== $this->createOrUpdateUserFromUserinfo($userinfo)) { - return new SelfValidatingPassport(new UserBadge($username)); + $user = $this->createOrUpdateUserFromUserinfo($userinfo); + + if (null === $user) { + throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); } - throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich'); + // The user is resolved here rather than by the user provider: the account is + // matched on the BusPro ids, which the provider cannot do, and handing it an + // email would risk loading a different row than the one just written. The + // UserChecker still runs and still refuses deleted, disabled and unapproved + // accounts. + return new SelfValidatingPassport( + new UserBadge($user->getUserIdentifier(), static fn (): User => $user), + ); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response @@ -89,13 +118,26 @@ class MyEpAuthenticator extends AbstractAuthenticator /** @var User $user */ $user = $token->getUser(); + // recorded here rather than while importing, so that only a login that actually + // passed the UserChecker is counted as one + $user->setLastLoginAt(new \DateTimeImmutable()); + $this->entityManager->flush(); + $this->logger->info('Logged in'); if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { return new RedirectResponse($targetPath); } - $url = $this->urlGenerator->generate($user->getDefaultRoute()); + $route = $user->getDefaultRoute(); + if ('app_teamer_index' === $route) { + $check = $this->requiredTeamerCheckRegistry->getFirstUnresolvedCheck($user); + if (null !== $check) { + $route = $check->getRouteName(); + } + } + + $url = $this->urlGenerator->generate($route); return new RedirectResponse($url); } @@ -111,57 +153,185 @@ class MyEpAuthenticator extends AbstractAuthenticator private function createOrUpdateUserFromUserinfo(array $userinfo): ?User { - // User is expected to have at least one role - if (false === isset($userinfo['roles']) || 0 === count($userinfo['roles'])) { - // Claim keys only, the payload itself carries the full profile - $this->logger->warning('Login via MyE&P failed due to missing roles claim', [ - 'claims' => array_keys($userinfo), - ]); + $claimedRoles = $this->collectClaimedRoles($userinfo); + if ([] === $claimedRoles) { return null; } - // User is expected to have at least one of the roles teamer, manager, house manager or admin - if ([] === array_intersect(self::ELIGIBLE_ROLES, $userinfo['roles'])) { - $this->logger->warning('Login via MyE&P failed due to lack of an eligible role', [ - 'roles' => $userinfo['roles'], - 'eligible_roles' => self::ELIGIBLE_ROLES, + $isTeamer = in_array('ROLE_TEAMER', $claimedRoles, true); + $pendingRoles = $this->userDataHandler->toPendingRoles($claimedRoles); + + $user = $this->findLocalUser($userinfo); + + // A deleted account is excluded from every process, and MyE&P must not be able to + // undo that: no data is written back, no role is granted or revoked, not even + // lastLoginAt is bumped. It is returned untouched so the UserChecker can refuse + // the login and say why. Only an admin restores it. + if (true === $user?->isDeleted()) { + $this->logger->info('Skip MyE&P sync for deleted user', [ + 'user_id' => $user->getId(), + 'user_email' => $userinfo['email'], ]); - return null; + return $user; } - // Check if user is already present in local database - $user = $this - ->entityManager - ->getRepository(User::class) - ->findOneBy(['email' => $userinfo['email']]) - ; - - // Update existing user's roles and teamer data and return it + // Update existing user, leaving granted roles and hotel codes alone: they are + // imported once on creation and managed manually afterwards. Only the + // privilege-free markers and ROLE_TEAMER track MyE&P on every login. if (null !== $user) { - $user - ->setRoles($userinfo['roles']) - ->setHotelCodes($userinfo['profile']['hotel_codes']) - ->setLastLoginAt(new \DateTimeImmutable('now')) - ; + $this->refreshBusProIds($user, $userinfo); + $this->userDataHandler->refreshPendingRoles($user, $pendingRoles); + + if (true === $isTeamer) { + $this->userDataHandler->grantTeamerRole($user); + } $this->entityManager->flush(); return $user; } + return $this->createLocalUser($userinfo, $pendingRoles, $isTeamer); + } + + /** + * The roles MyE&P grants this person in this application. + * + * The remote list is filtered down to the eligible roles rather than trusted as it + * stands: anything else the identity provider reports is meaningless here at best, + * and at worst a role this application assigns a meaning to - ROLE_ADMINISTRATIVE + * from the role hierarchy, say - which must never be settable from the outside. + * + * @return string[] + */ + private function collectClaimedRoles(array $userinfo): array + { + $roles = $userinfo['roles'] ?? []; + + // User is expected to have at least one role + if (false === is_array($roles) || [] === $roles) { + // Claim keys only, the payload itself carries the full profile + $this->logger->warning('Login via MyE&P failed due to missing roles claim', [ + 'claims' => array_keys($userinfo), + ]); + + return []; + } + + // User is expected to have at least one of the roles teamer, manager, house manager or admin + $claimedRoles = array_values(array_intersect(self::ELIGIBLE_ROLES, $roles)); + + if ([] === $claimedRoles) { + $this->logger->warning('Login via MyE&P failed due to lack of an eligible role', [ + 'roles' => $roles, + 'eligible_roles' => self::ELIGIBLE_ROLES, + ]); + } + + return $claimedRoles; + } + + /** + * Matches the local account on the BusPro ids first and falls back to the email, the + * same precedence UserDataHandler::findLocalUser() applies to a BusPro login, so that + * the two login paths cannot create two accounts for the same person. + */ + private function findLocalUser(array $userinfo): ?User + { + $repository = $this->entityManager->getRepository(User::class); + + $addressId = $userinfo['address_id'] ?? null; + $personId = $userinfo['person_id'] ?? null; + + if (null !== $addressId && null !== $personId) { + $user = $repository->findOneBy([ + 'busProAddressId' => $addressId, + 'busProPersonId' => $personId, + ]); + + if (null !== $user) { + return $user; + } + } + + $users = $repository->findBy(['email' => $userinfo['email']]); + + if (1 !== count($users)) { + if (1 < count($users)) { + $this->logger->warning('Unable to match local user by email: multiple users found', [ + 'email' => $userinfo['email'], + 'count' => count($users), + ]); + } + + return null; + } + + return $users[0]; + } + + /** + * Backfills the BusPro ids on an account matched by email, so the next login matches + * on the ids instead. Never called for a deleted user, who must not be written to. + */ + private function refreshBusProIds(User $user, array $userinfo): void + { + $addressId = $userinfo['address_id'] ?? null; + $personId = $userinfo['person_id'] ?? null; + + if (null === $addressId || null === $personId) { + $this->logger->warning('Skip BusPro ID refresh: userinfo missing IDs', [ + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail(), + 'bus_pro_address_id' => $addressId, + 'bus_pro_person_id' => $personId, + ]); + + return; + } + + $user + ->setBusProAddressId((int) $addressId) + ->setBusProPersonId((int) $personId) + ; + } + + /** + * @param string[] $pendingRoles + */ + private function createLocalUser(array $userinfo, array $pendingRoles, bool $isTeamer): ?User + { + $addressId = $userinfo['address_id'] ?? null; + $personId = $userinfo['person_id'] ?? null; + + // Both are required columns and there is nothing sensible to fall back to, so an + // account is not created at all rather than half-created from a partial payload + if (null === $addressId || null === $personId) { + $this->logger->warning('Login via MyE&P failed due to missing BusPro ID claims', [ + 'email' => $userinfo['email'], + 'claims' => array_keys($userinfo), + ]); + + return null; + } + + $profile = is_array($userinfo['profile'] ?? null) ? $userinfo['profile'] : []; + $hotelCodes = is_array($profile['hotel_codes'] ?? null) ? $profile['hotel_codes'] : []; + $user = new User(); $user - ->setFirstName($userinfo['profile']['first_name']) - ->setLastName($userinfo['profile']['last_name']) - ->setEmail($userinfo['profile']['communication']['email']) - ->setBusProPersonId($userinfo['id']) - ->setHotelCodes($userinfo['profile']['hotel_codes']) - ->setRoles($userinfo['roles']) + ->setFirstName($profile['first_name'] ?? null) + ->setLastName($profile['last_name'] ?? null) + ->setEmail((string) $userinfo['email']) + ->setBusProAddressId((int) $addressId) + ->setBusProPersonId((int) $personId) + ->setHotelCodes($hotelCodes) + ->setRoles([...$pendingRoles, ...($isTeamer ? ['ROLE_TEAMER'] : [])]) ; - if (true === in_array('ROLE_TEAMER', $user->getRoles(), true)) { + if (true === $isTeamer) { $teamer = Teamer::fromUserinfo($userinfo); $user->setTeamer($teamer); @@ -176,6 +346,11 @@ class MyEpAuthenticator extends AbstractAuthenticator $this->entityManager->persist($user); $this->entityManager->flush(); + $this->logger->info('Create user', [ + 'user_id' => $user->getId(), + 'user_email' => $user->getEmail(), + ]); + return $user; } } diff --git a/tests/Security/MyEpAuthenticatorTest.php b/tests/Security/MyEpAuthenticatorTest.php new file mode 100644 index 0000000..953432a --- /dev/null +++ b/tests/Security/MyEpAuthenticatorTest.php @@ -0,0 +1,394 @@ +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('user@example.com')->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('user@example.com')->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('user@example.com')->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('user@example.com')->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('user@example.com', $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('user@example.com')->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('user@example.com')->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('a@example.com'), (new User())->setEmail('b@example.com')]); + + $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' => 'user@example.com', + 'person_id' => 12345, + 'address_id' => 6789, + 'roles' => $roles, + 'profile' => [ + 'first_name' => 'Erika', + 'last_name' => 'Musterfrau', + 'hotel_codes' => ['HOTEL'], + 'communication' => [ + 'email' => 'private@example.com', + ], + ], + ]; + } + + 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), + ); + } +}