feat: streamlined role-revocation logic
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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="<ohne Gruppe>">
|
||||
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="10" bezeichnung="TEAM">
|
||||
<selektion id="1070" bezeichnung="E&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="<ohne Gruppe>">
|
||||
<selektion id="1156" bezeichnung="Gruppen-Buchungsportal" aenderbar="False" auswahl="True"></selektion>
|
||||
</selektionsgruppe>
|
||||
<selektionsgruppe id="10" bezeichnung="TEAM">
|
||||
<selektion id="1070" bezeichnung="E&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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user