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
+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();
}
}