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), ); } }