dispatched = []; } public function testNewAccountIsSeededFromTheCrm(): void { $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']), null, $persisted, ); $user = $this->loadUser($authenticator); self::assertSame($persisted, $user); self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles()); self::assertSame(['SSL'], $user->getHotelCodes()); } public function testAdministrativeClaimIsOnlyANominationUntilItIsApproved(): void { $existing = (new User('teamer@example.org'))->setRoles([Role::TEAMER]); $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []), $existing, $persisted, ); $user = $this->loadUser($authenticator); self::assertSame($existing, $persisted, 'a login must not create a second account'); self::assertSame( ['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], $user->getRoles(), ); self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced'); } public function testApprovedRoleSurvivesTheNextLogin(): void { $existing = (new User('manager@example.org'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]); $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []), $existing, $persisted, ); self::assertSame( ['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $this->loadUser($authenticator)->getRoles(), ); } public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void { $existing = (new User('manager@example.org'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]); $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted); // Nothing is claimed any more, so nothing is held — and an account without an effective // role is a customer. self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles()); } public function testRoleGainedInBusProIsGrantedOnLogin(): void { $existing = (new User('teamer@example.org'))->setRoles([Role::CUSTOMER]); $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([Role::TEAMER], []), $existing, $persisted, ); // The case myep-team depends on: somebody becomes a Teamer after their account exists. self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles()); } public function testHotelCodesAreResyncedOnEveryLogin(): void { $existing = (new User('house@example.org')) ->setRoles([Role::HOUSE_MANAGER]) ->setHotelCodes(['DKS', 'SSL']) ; $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([Role::HOUSE_MANAGER], ['DKS']), $existing, $persisted, ); self::assertSame(['DKS'], $this->loadUser($authenticator)->getHotelCodes()); } public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void { $existing = (new User('manager@example.org')) ->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]) ->setHotelCodes(['DKS']) ; $persisted = null; // No selection groups at all: BusPro always answers with the full attribute tree, so // this is a degraded payload and not a revocation of everything. $authenticator = $this->authenticator( $this->crmAttributes([], [], selectionGroups: []), $existing, $persisted, ); $user = $this->loadUser($authenticator); self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles()); self::assertSame(['DKS'], $user->getHotelCodes()); } public function testDegradedCrmResponseStillGivesANewAccountTheFallbackRole(): void { $persisted = null; $authenticator = $this->authenticator( $this->crmAttributes([], [], selectionGroups: []), null, $persisted, ); self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles()); } public function testANewAccountIsOnlyRegisteredOnceItCarriesItsPassword(): void { $persisted = null; $persistedPassword = null; // A degraded payload is what makes syncFromCrm() log, and that log write reaches the // database. An account registered before the profile is complete would be written out // half-built, and the user table rejects it: password is NOT NULL. $authenticator = $this->authenticator( $this->crmAttributes([], [], selectionGroups: []), null, $persisted, $persistedPassword, ); $this->loadUser($authenticator); self::assertInstanceOf(User::class, $persisted); self::assertSame('encrypted', $persistedPassword); } /** * @param string[] $roles * @param string[] $hotelCodes * @param CrmSelectionGroup[] $selectionGroups only their presence matters here — an empty * set is what marks a response as degraded */ private function crmAttributes(array $roles, array $hotelCodes, ?array $selectionGroups = null): CrmAttributes { $attributes = new CrmAttributes(); $attributes->roles = $roles; $attributes->hotelCodes = $hotelCodes; $attributes->selectionGroups = $selectionGroups ?? [new CrmSelectionGroup()]; return $attributes; } public function testStaffEmailDomainGrantsTheEmployeeRole(): void { $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted); $user = $this->loadUser($authenticator, 'someone@ep-reisen.de'); // Effective, so it displaces the customer fallback the same account would get otherwise. self::assertSame(['ROLE_USER', Role::EMPLOYEE], $user->getRoles()); } public function testAnotherEmailDomainStillFallsBackToCustomer(): void { $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted); $user = $this->loadUser($authenticator, 'someone@example.org'); self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles()); } public function testEmployeeRoleIsWithdrawnWhenTheAddressIsNoLongerStaff(): void { $existing = (new User('someone@example.org'))->setRoles([Role::EMPLOYEE]); $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted); $user = $this->loadUser($authenticator, 'someone@example.org'); self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles()); } public function testANewNominationIsAnnouncedOnce(): void { $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted); $user = $this->loadUser($authenticator); self::assertCount(1, $this->dispatched); $message = $this->dispatched[0]; self::assertInstanceOf(RoleNominationMessage::class, $message); // The role itself, not its marker: the marker is an internal bookkeeping detail. self::assertSame([Role::ADMIN], $message->roles); self::assertContains(Role::pending(Role::ADMIN), $user->getRoles()); } public function testAStandingNominationIsNotAnnouncedAgain(): void { $existing = (new User('teamer@example.org'))->setRoles([Role::pending(Role::ADMIN)]); $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted); $this->loadUser($authenticator); // The nomination has not changed, so there is nothing new to tell an administrator about. self::assertSame([], $this->dispatched); } public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void { $existing = (new User('teamer@example.org'))->setRoles([Role::ADMIN]); $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted); $this->loadUser($authenticator); self::assertSame([], $this->dispatched); } public function testALoginWithoutANominationAnnouncesNothing(): void { $persisted = null; $authenticator = $this->authenticator($this->crmAttributes([Role::TEAMER], []), null, $persisted); $this->loadUser($authenticator); self::assertSame([], $this->dispatched); } private function authenticator( CrmAttributes $crmAttributes, ?User $existing, ?User &$persisted, ?string &$persistedPassword = null, ): BpnAuthenticator { $personalData = new PersonalData(); $personalData->personId = 42; $personalData->addressId = 4711; $apiClient = $this->createStub(ApiClient::class); $apiClient->method('getPersonalData')->willReturn($personalData); $apiClient->method('getCrmAttributes')->willReturn($crmAttributes); $repository = $this->createStub(EntityRepository::class); $repository->method('findOneBy')->willReturn($existing); $entityManager = $this->createStub(EntityManagerInterface::class); $entityManager->method('getRepository')->willReturn($repository); $entityManager ->method('persist') ->willReturnCallback(static function (object $entity) use (&$persisted, &$persistedPassword): void { $persisted = $entity; // Snapshot rather than a reference: what matters is what the account looked like // at the moment it was registered, not what it grew into afterwards. $persistedPassword = $entity instanceof User ? $entity->getPassword() : null; }) ; $crypt = $this->createStub(Crypt::class); $crypt->method('encrypt')->willReturn('encrypted'); $completenessChecker = $this->createStub(ProfileCompletenessChecker::class); $completenessChecker->method('isComplete')->willReturn(true); $messageBus = $this->createStub(MessageBusInterface::class); $messageBus ->method('dispatch') ->willReturnCallback(function (object $message): Envelope { $this->dispatched[] = $message; return new Envelope($message); }) ; return new BpnAuthenticator( $this->createStub(UrlGeneratorInterface::class), $apiClient, $entityManager, $crypt, $completenessChecker, $this->createStub(LoggerInterface::class), new EmployeeDomainMatcher(['ep-reisen.de']), $messageBus, ); } private function loadUser(BpnAuthenticator $authenticator, string $email = 'teamer@example.org'): User { $request = new Request(); $request->request->set('_username', $email); $request->request->set('_password', 'secret'); $badge = $authenticator->authenticate($request)->getBadge(UserBadge::class); self::assertInstanceOf(UserBadge::class, $badge); $user = $badge->getUser(); self::assertInstanceOf(User::class, $user); return $user; } }