feat: streamlined role-revocation logic

This commit is contained in:
Björn Fromme
2026-08-10 16:24:41 +02:00
parent b39a78da82
commit fd5d478a5c
14 changed files with 426 additions and 22 deletions
@@ -87,12 +87,13 @@ class CrmAttributesResponse
{
$crmSelections = [];
foreach ($this->getAttributeGroups() as $group) {
// a response the CRM sent no attributes in still has to flatten to an empty set
foreach ($this->getAttributeGroups() ?? [] as $group) {
/** @var CrmAttributeGroup $group */
if (false === isset($crmSelections[$group->getLabel()])) {
$crmSelections[$group->getLabel()] = [];
}
foreach ($group->getAttributes() as $attribute) {
foreach ($group->getAttributes() ?? [] as $attribute) {
/** @var CrmAttribute $attribute */
if (false === $attribute->isSelected()) {
continue;
+42 -8
View File
@@ -165,10 +165,11 @@ class UserDataHandler
/**
* Updates an existing user from BusPro data.
*
* Roles and hotel codes are imported once on user creation only and are managed
* manually afterwards, so they are intentionally left untouched here. The only
* exception are the privilege-free pending markers, which keep tracking the
* administrative roles claimed in the CRM.
* Administrative roles and hotel codes are imported once on user creation only and are
* managed manually afterwards, so they are intentionally left untouched here. The
* exceptions are the privilege-free pending markers, which keep tracking the
* administrative roles claimed in the CRM, and ROLE_TEAMER, which needs no approval
* and is granted to whoever the CRM reports as a teamer.
*
* @param string[] $claimedRoles pending markers as returned by collectPendingRoles()
*/
@@ -188,6 +189,8 @@ class UserDataHandler
$this->refreshPendingRoles($user, $claimedRoles);
if (true === $isTeamer) {
$this->grantTeamerRole($user);
$address = Address::fromApiResponse($profileResponse);
$communication = Communication::fromApiResponse($profileResponse);
if (null === $user->getTeamer()) {
@@ -221,15 +224,19 @@ class UserDataHandler
/**
* Blocks a user the CRM no longer grants anything in this application.
*
* The granted roles are deliberately kept: they stay visible for review and are what
* makes the user reappear in the administrative list, where a super admin can unblock
* them. Only the privilege-free markers are dropped, as they no longer reflect the CRM.
* The granted roles are deliberately kept: they stay visible for review. Only the
* privilege-free markers are dropped, as they no longer reflect the CRM. What keeps the
* user reachable is the block itself, not the roles: a teamer stays in the teamer list,
* everyone else is listed by getAdministrativeUsers() whatever roles are left.
* Regaining a CRM role does not unblock the account, that is a manual decision.
*/
public function disableForRevokedCrmRoles(User $user): void
{
// an existing block may be a disciplinary one and must never be overwritten
// an existing block may be a disciplinary one and must never be overwritten, but
// findLocalUser() may have refreshed the BusPro ids and nothing else flushes here
if (true === $user->isDisabled()) {
$this->entityManager->flush();
return;
}
@@ -249,6 +256,33 @@ class UserDataHandler
]);
}
/**
* Grants ROLE_TEAMER to a user the CRM reports as a teamer.
*
* Unlike the administrative roles this one needs no approval, so it may be granted on
* any login rather than on creation only: it carries no privileges beyond the teamer
* area, and a teamer without it would be left with a teamer record they cannot reach,
* or locked out entirely for holding no assignable role at all.
*
* It is never withdrawn here. Losing the CRM attribute while holding no other role
* blocks the account anyway, and a role handed out manually must survive a login.
*/
private function grantTeamerRole(User $user): void
{
$grantedRoles = $user->getAssignedRoles();
if (true === in_array('ROLE_TEAMER', $grantedRoles, true)) {
return;
}
$user->setRoles([...$grantedRoles, 'ROLE_TEAMER', ...$user->getPendingRoles()]);
$this->logger->info('Grant teamer role', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
]);
}
/**
* Keeps the pending markers in sync with the administrative roles claimed in the CRM.
* The markers grant no privileges, so tracking them on every login is safe: only a
@@ -29,7 +29,7 @@ class DisableUserController extends AbstractController
$form = $this->createForm(DisableUserType::class, $user, ['hx_post' => $request->getUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setDisabledAt(new \DateTimeImmutable());
$user->setDisabled(true);
$this->entityManager->flush();
@@ -56,11 +56,7 @@ class DisableUserController extends AbstractController
$user = $teamer->getUser();
if (true === $request->isMethod(Request::METHOD_POST)) {
$user
->setDisabledAt(null)
->setDisabledReason(null)
->setDisabledReasonInternal(null)
;
$user->setDisabled(false);
$this->entityManager->flush();
+25 -2
View File
@@ -33,8 +33,6 @@ class UserType extends AbstractType
'required' => false,
'help' => 'Setzt die Rolle Admin voraus.',
])
// must stay ahead of the reason: properties are written in field order and
// unblocking clears the reasons
->add('disabled', CheckboxType::class, [
'label' => 'Account gesperrt',
'required' => false,
@@ -49,8 +47,33 @@ class UserType extends AbstractType
'data-action' => 'textarea-autosize#resize',
],
])
->add('disabledReasonInternal', TextareaType::class, [
'label' => 'Begründung intern',
'required' => false,
'help' => 'Wird der Benutzer:in nicht angezeigt.',
'attr' => [
'data-controller' => 'textarea-autosize',
'data-action' => 'textarea-autosize#resize',
],
])
;
// the reasons only ever describe a block, so an unblocked account carries none.
// Done after mapping instead of relying on the field order, as the submitted text
// would otherwise be written back over the reset done by setDisabled()
$builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event): void {
$user = $event->getData();
if (false === $user instanceof User || true === $user->isDisabled()) {
return;
}
$user
->setDisabledReason(null)
->setDisabledReasonInternal(null)
;
});
// hotel codes already assigned to the user may predate the catalog, so they are
// added as choices to keep them selectable instead of failing
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
+10
View File
@@ -25,6 +25,11 @@ class UserRepository extends ServiceEntityRepository
}
/**
* Users holding an administrative role or awaiting approval for one, plus blocked
* accounts without a teamer. A user demoted by the CRM may keep no role that would list
* them, and a block is only ever lifted from a list: teamers are unblocked from theirs,
* everyone else has none but this one.
*
* @return User[]
*/
public function getAdministrativeUsers(): array
@@ -41,6 +46,11 @@ class UserRepository extends ServiceEntityRepository
;
}
$qb->orWhere($qb->expr()->andX(
$qb->expr()->isNotNull('u.disabledAt'),
$qb->expr()->isNull('u.teamer'),
));
return $qb
->orderBy('u.lastName', 'ASC')
->getQuery()
+19 -1
View File
@@ -112,12 +112,16 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
): ?User {
// Fetch CRM attributes, early return in case of an API error
try {
/** @var CrmAttributesResponse $crmAttributes */
$crmAttributes = $this->apiClient->getCrmAttributes($email, $password);
} catch (ApiClientException $e) {
return null;
}
// BusPro answers with a notification record instead of the data on its own errors
if (false === $crmAttributes instanceof CrmAttributesResponse) {
return null;
}
// Flatten selected CRM attributes
$crmSelections = $crmAttributes->toArray();
@@ -140,6 +144,20 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
// are no user of it: never create an account, block an existing one. Returning
// the blocked user lets the UserChecker explain why the login was refused.
if ([] === $claimedRoles) {
// A response without any attribute group carries no roles either, so it looks
// exactly like a revocation while it really means the CRM told us nothing:
// an empty payload, a changed schema, a misconfigured attribute id. Blocking
// on that would lock out every user logging in, so refuse this single login
// instead and leave the account alone.
if ([] === ($crmAttributes->getAttributeGroups() ?? [])) {
$this->logger->warning('Skip demotion: CRM attributes response carries no attribute groups', [
'user_id' => $user?->getId(),
'user_email' => $email,
]);
return null;
}
if (null === $user) {
return null;
}
+1 -1
View File
@@ -59,7 +59,7 @@ class FeedbackReminderService
$hotelManagers = $this
->userRepository
->getUsersByRoleAndHotelCode('ROLE_HOTEL_MANAGER', $hotelBaseCodes)
->getUsersByRoleAndHotelCode('ROLE_HOUSE_MANAGER', $hotelBaseCodes)
;
if (0 === count($hotelManagers)) {
@@ -5,6 +5,7 @@
{{ form_row(form.hotelCodes) }}
{{ form_row(form.disabled) }}
{{ form_row(form.disabledReason) }}
{{ form_row(form.disabledReasonInternal) }}
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
+58
View File
@@ -48,6 +48,59 @@ class ResponseParserTest extends TestCase
$this->assertTrue($response->isTeamer());
}
/**
* BusPro always returns the full attribute tree and expresses the roles a person holds
* through the "auswahl" flag, so a revoked role arrives as a selection set to False,
* never as a missing group. The demotion path in BpnAuthenticator relies on that: it
* treats "no roles" as a revocation only when attribute groups are present.
*/
public function testParseCrmAttributesOfAUserHoldingEveryRole(): void
{
$parser = $this->getParserInstance();
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $this->loadFixture('crm_attributes_granted.xml'));
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
$this->assertTrue($response->isTeamer());
$this->assertTrue($response->isAdmin());
$this->assertTrue($response->isManager());
$this->assertTrue($response->isHouseManager());
$this->assertSame(['DKS'], $response->getHotelCodes());
// "Preisrechner Admin" is matched by id, never by its label
$this->assertCount(3, $response->getAttributeGroups());
}
public function testParseCrmAttributesOfAUserWhoseRolesWereRevoked(): void
{
$parser = $this->getParserInstance();
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $this->loadFixture('crm_attributes_revoked.xml'));
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
$this->assertFalse($response->isTeamer());
$this->assertFalse($response->isAdmin());
$this->assertFalse($response->isManager());
$this->assertFalse($response->isHouseManager());
$this->assertSame([], $response->getHotelCodes());
// the groups still arrive, so this is a revocation and not a degraded response
$this->assertCount(3, $response->getAttributeGroups());
}
public function testParseCrmAttributesOfAnEmptyResponse(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>SelektionCRM</art><idadresse>141747</idadresse><idperson>224526</idperson><selektionsmerkmale></selektionsmerkmale></ergebnis>';
$parser = $this->getParserInstance();
$response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content);
// indistinguishable from a revocation by the roles alone, which is why the empty
// group set is what the demotion path checks
$this->assertInstanceOf(CrmAttributesResponse::class, $response);
$this->assertFalse($response->isTeamer());
$this->assertSame([], $response->getAttributeGroups());
$this->assertSame([], $response->toArray());
}
public function testParseSuccessfulProfileUpdateResponseWithoutAddressData(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Adressdaten_Ändern</art><idadresse>141747</idadresse><idperson>224526</idperson><änderung>True</änderung></ergebnis>';
@@ -106,6 +159,11 @@ class ResponseParserTest extends TestCase
$this->assertEquals('Gaststätte', $hotel->getType());
}
private function loadFixture(string $filename): string
{
return file_get_contents(__DIR__.'/../Resources/'.$filename);
}
private function getParserInstance(): ResponseParser
{
return new ResponseParser([
+51 -2
View File
@@ -213,6 +213,54 @@ class UserDataHandlerTest extends TestCase
];
}
public function testUpdateLocalUserGrantsTheTeamerRoleToAUserWhoBecameATeamer(): void
{
// created as a candidate for approval, made a teamer in the CRM afterwards
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles([User::PENDING_ROLES['ROLE_ADMIN']])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], [User::PENDING_ROLES['ROLE_ADMIN']]);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
}
public function testUpdateLocalUserKeepsTheTeamerRoleOfSomebodyTheCrmNoLongerReportsAsTeamer(): void
{
// the role may have been granted manually and must survive a login
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], []);
$this->assertSame(['ROLE_ADMIN', 'ROLE_TEAMER'], $user->getAssignedRoles());
}
public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_TEAMER'])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], []);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
}
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
{
$user = (new User())
@@ -239,7 +287,7 @@ class UserDataHandlerTest extends TestCase
$this->assertSame([], $user->getPendingRoles());
}
public function testDisableForRevokedCrmRolesLeavesAnExistingBlockUntouched(): void
public function testDisableForRevokedCrmRolesLeavesAnExistingBlockUntouchedButStillFlushes(): void
{
$disabledAt = new \DateTimeImmutable('2026-01-01 08:00:00');
@@ -253,8 +301,9 @@ class UserDataHandlerTest extends TestCase
->setDisabledReasonInternal('Siehe Vorgang 4711.')
;
// findLocalUser() may have refreshed the BusPro ids on the way here
$this->entityManager
->expects($this->never())
->expects($this->once())
->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
+30
View File
@@ -95,11 +95,13 @@ class UserTypeTest extends KernelTestCase
'hotelCodes' => [],
'disabled' => '1',
'disabledReason' => 'Wegen Fehlverhaltens gesperrt.',
'disabledReasonInternal' => 'Siehe Vorgang 4711.',
]);
$this->assertTrue($form->isValid());
$this->assertTrue($user->isDisabled());
$this->assertSame('Wegen Fehlverhaltens gesperrt.', $user->getDisabledReason());
$this->assertSame('Siehe Vorgang 4711.', $user->getDisabledReasonInternal());
}
public function testSubmitUnblocksTheAccountAndClearsTheReasons(): void
@@ -118,6 +120,7 @@ class UserTypeTest extends KernelTestCase
'hotelCodes' => [],
'disabled' => null,
'disabledReason' => null,
'disabledReasonInternal' => null,
]);
$this->assertTrue($form->isValid());
@@ -127,6 +130,33 @@ class UserTypeTest extends KernelTestCase
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
}
public function testSubmitUnblockingClearsTheReasonsEvenWhenTheirFieldsAreStillFilled(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setDisabledAt(new \DateTimeImmutable('2026-01-01 08:00:00'))
->setDisabledReason('Für deinen Account liegt in BusPro keine Berechtigung mehr vor.')
->setDisabledReasonInternal('Automatisch gesperrt: keine Rollen in BusPro.')
;
$form = $this->createForm($user);
// the textareas are prefilled, so unchecking the box alone submits the old reasons
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => null,
'disabledReason' => 'Für deinen Account liegt in BusPro keine Berechtigung mehr vor.',
'disabledReasonInternal' => 'Automatisch gesperrt: keine Rollen in BusPro.',
]);
$this->assertTrue($form->isValid());
$this->assertFalse($user->isDisabled());
$this->assertNull($user->getDisabledReason());
$this->assertNull($user->getDisabledReasonInternal());
}
private function createForm(User $user): \Symfony\Component\Form\FormInterface
{
self::bootKernel();
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO"></satz>
<art>SelektionCRM</art>
<idadresse>141747</idadresse>
<idperson>224526</idperson>
<selektionsmerkmale>
<selektionsgruppe id="0" bezeichnung="&lt;ohne Gruppe&gt;">
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
</selektionsgruppe>
<selektionsgruppe id="10" bezeichnung="TEAM">
<selektion id="1070" bezeichnung="E&amp;P Teamer - allg. Merkmal" aenderbar="False" auswahl="True"></selektion>
<selektion id="1292" bezeichnung="Admin" aenderbar="False" auswahl="True"></selektion>
<selektion id="1293" bezeichnung="Manager" aenderbar="False" auswahl="True"></selektion>
<selektion id="1299" bezeichnung="Hausleitung SSL" aenderbar="False" auswahl="False"></selektion>
<selektion id="1304" bezeichnung="Hausleitung DKS" aenderbar="False" auswahl="True"></selektion>
<selektion id="1305" bezeichnung="Hausleitung DGS" aenderbar="False" auswahl="False"></selektion>
<selektion id="1477" bezeichnung="Preisrechner" aenderbar="False" auswahl="False"></selektion>
<selektion id="1478" bezeichnung="Preisrechner Admin" aenderbar="False" auswahl="False"></selektion>
</selektionsgruppe>
<selektionsgruppe id="83" bezeichnung="Interessen">
<selektion id="1067" bezeichnung="Eventreisen" aenderbar="True" auswahl="False"></selektion>
<selektion id="1064" bezeichnung="Sportclub-Reisen" aenderbar="True" auswahl="False"></selektion>
</selektionsgruppe>
</selektionsmerkmale>
<crmaktionen>
<crmaktion id="276" code="NOMAIL" bezeichnung="Ich möchte keine Werbung per Mail erhalten" aenderbar="True" auswahl="True"></crmaktion>
</crmaktionen>
</ergebnis>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="KUNDENKONTO"></satz>
<art>SelektionCRM</art>
<idadresse>141747</idadresse>
<idperson>224526</idperson>
<selektionsmerkmale>
<selektionsgruppe id="0" bezeichnung="&lt;ohne Gruppe&gt;">
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
</selektionsgruppe>
<selektionsgruppe id="10" bezeichnung="TEAM">
<selektion id="1070" bezeichnung="E&amp;P Teamer - allg. Merkmal" aenderbar="False" auswahl="False"></selektion>
<selektion id="1292" bezeichnung="Admin" aenderbar="False" auswahl="False"></selektion>
<selektion id="1293" bezeichnung="Manager" aenderbar="False" auswahl="False"></selektion>
<selektion id="1299" bezeichnung="Hausleitung SSL" aenderbar="False" auswahl="False"></selektion>
<selektion id="1304" bezeichnung="Hausleitung DKS" aenderbar="False" auswahl="False"></selektion>
<selektion id="1305" bezeichnung="Hausleitung DGS" aenderbar="False" auswahl="False"></selektion>
<selektion id="1477" bezeichnung="Preisrechner" aenderbar="False" auswahl="False"></selektion>
<selektion id="1478" bezeichnung="Preisrechner Admin" aenderbar="False" auswahl="False"></selektion>
</selektionsgruppe>
<selektionsgruppe id="83" bezeichnung="Interessen">
<selektion id="1067" bezeichnung="Eventreisen" aenderbar="True" auswahl="False"></selektion>
<selektion id="1064" bezeichnung="Sportclub-Reisen" aenderbar="True" auswahl="False"></selektion>
</selektionsgruppe>
</selektionsmerkmale>
<crmaktionen>
<crmaktion id="276" code="NOMAIL" bezeichnung="Ich möchte keine Werbung per Mail erhalten" aenderbar="True" auswahl="True"></crmaktion>
</crmaktionen>
</ergebnis>
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\UserDataHandler;
use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use App\Security\BpnAuthenticator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
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\UserNotFoundException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class BpnAuthenticatorTest extends TestCase
{
private ApiClient&MockObject $apiClient;
private UserDataHandler&MockObject $userDataHandler;
protected function setUp(): void
{
$this->apiClient = $this->createMock(ApiClient::class);
$this->userDataHandler = $this->createMock(UserDataHandler::class);
}
public function testUnknownUserWithoutClaimedRolesIsNeverCreated(): void
{
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn(null);
$this->userDataHandler->expects($this->never())->method('createLocalUser');
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->expectException(UserNotFoundException::class);
$this->loadUser();
}
public function testExistingUserWithoutClaimedRolesIsBlockedAndReturned(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
$this->userDataHandler
->expects($this->once())
->method('disableForRevokedCrmRoles')
->with($user)
;
// returned rather than refused, so the UserChecker can explain the block
$this->assertSame($user, $this->loadUser());
}
public function testResponseWithoutAttributeGroupsRefusesTheLoginWithoutBlocking(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
// an empty payload carries no roles either and must not read as a revocation
$this->stubApiClient(new CrmAttributesResponse());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->expectException(UserNotFoundException::class);
$this->loadUser();
}
private function createCrmAttributes(): CrmAttributesResponse
{
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
}
private function stubApiClient(CrmAttributesResponse $crmAttributes): void
{
$this->apiClient->method('getProfile')->willReturn(new ProfileResponse());
$this->apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
}
private function loadUser(): ?User
{
$authenticator = new BpnAuthenticator(
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(EntityManagerInterface::class),
$this->apiClient,
$this->createMock(LoggerInterface::class),
$this->userDataHandler,
$this->createMock(RequiredTeamerCheckRegistry::class),
);
$request = new Request(request: [
'_username' => '[email protected]',
'_password' => 'secret',
'_csrf_token' => 'token',
]);
$request->setSession(new Session(new MockArraySessionStorage()));
$passport = $authenticator->authenticate($request);
/** @var UserBadge $badge */
$badge = $passport->getBadge(UserBadge::class);
/* @var User $user */
return $badge->getUser();
}
}