feat: bpn as single source of truth for role and hotel code assignments

This commit is contained in:
Björn Fromme
2026-08-18 11:33:42 +02:00
parent 1fd0fbc21e
commit f0e850978b
17 changed files with 818 additions and 198 deletions
+152 -20
View File
@@ -72,9 +72,9 @@ class UserDataHandlerTest extends TestCase
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'manager takes precedence over house manager' => [
yield 'manager and house manager yield both markers, the roles stand on their own' => [
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'house manager and teamer' => [
@@ -105,10 +105,10 @@ class UserDataHandlerTest extends TestCase
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'manager takes precedence over house manager' => [
yield 'manager and house manager are marked independently' => [
['ROLE_HOUSE_MANAGER', 'ROLE_MANAGER'],
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'house manager' => [
@@ -172,14 +172,16 @@ class UserDataHandlerTest extends TestCase
$profileResponse,
true,
['team' => ['selected' => true]],
['ROLE_ADMIN', 'ROLE_TEAMER'],
['DKS'],
);
$this->assertSame('New', $user->getFirstName());
$this->assertSame('Lastname', $user->getLastName());
$this->assertSame('[email protected]', $user->getEmail());
// roles and hotel codes are imported on creation only and stay under manual control
$this->assertSame(['XYZ'], $user->getHotelCodes());
// the CRM leads: the still claimed role survives, the houses are replaced by its own
$this->assertSame(['DKS'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN'));
$this->assertSame('New', $teamer->getFirstName());
@@ -225,44 +227,51 @@ class UserDataHandlerTest extends TestCase
{
yield 'marker is added when the CRM claims a manager' => [
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER', 'ROLE_TEAMER'],
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'marker is dropped when the CRM attribute is gone' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
[],
['ROLE_TEAMER'],
['ROLE_TEAMER'],
[],
];
yield 'an approved role is never marked again' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
['ROLE_MANAGER'],
[],
];
yield 'a claim beyond the approved role stays pending' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_ADMIN', 'ROLE_MANAGER'],
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'the claimed role changes' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
[],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'granted roles are untouched without any claim' => [
['ROLE_ADMIN'],
yield 'a granted role is revoked once the CRM stops claiming it' => [
['ROLE_ADMIN', 'ROLE_TEAMER'],
['ROLE_TEAMER'],
['ROLE_TEAMER'],
[],
['ROLE_ADMIN'],
];
yield 'a manager who is also a house manager is marked for both' => [
[],
['ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'],
[],
[User::PENDING_ROLES['ROLE_MANAGER'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
}
@@ -277,15 +286,18 @@ class UserDataHandlerTest extends TestCase
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], [User::PENDING_ROLES['ROLE_ADMIN']]);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_ADMIN', 'ROLE_TEAMER']);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
}
public function testUpdateLocalUserKeepsTheTeamerRoleOfSomebodyTheCrmNoLongerReportsAsTeamer(): void
/**
* ROLE_TEAMER needs no approval, but it is not exempt from the sync either: the CRM
* leads, so the role goes when the attribute does.
*/
public function testUpdateLocalUserWithdrawsTheTeamerRoleOfSomebodyTheCrmNoLongerReportsAsTeamer(): void
{
// the role may have been granted manually and must survive a login
$user = (new User())
->setFirstName('First')
->setLastName('Last')
@@ -294,9 +306,9 @@ class UserDataHandlerTest extends TestCase
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], []);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']);
$this->assertSame(['ROLE_ADMIN', 'ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
}
public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void
@@ -309,11 +321,131 @@ class UserDataHandlerTest extends TestCase
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], []);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_TEAMER']);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
}
/**
* ROLE_SUPER_ADMIN is not a stored role but a flag getRoles() turns into one, so
* revoking ROLE_ADMIN has to take it down explicitly - otherwise the highest privilege
* in the application would outlive the role it depends on.
*/
public function testUpdateLocalUserTakesTheSuperAdminFlagDownWithRoleAdmin(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_TEAMER']);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertFalse($user->isSuperAdmin());
$this->assertFalse($user->hasRole('ROLE_SUPER_ADMIN'));
}
public function testUpdateLocalUserKeepsTheSuperAdminFlagOfAStillClaimedAdmin(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']);
$this->assertTrue($user->isSuperAdmin());
$this->assertTrue($user->hasRole('ROLE_SUPER_ADMIN'));
}
public function testUpdateLocalUserReplacesTheHotelCodesWithTheOnesTheCrmReports(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_HOUSE_MANAGER'])
->setHotelCodes(['SSL'])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_HOUSE_MANAGER'], ['DKS']);
$this->assertSame(['DKS'], $user->getHotelCodes());
}
/**
* Approval is the one place a role is granted at all, and it can only ever grant a role
* the CRM already claims - the marker is what says so.
*/
public function testApproveRoleGrantsTheRoleAndClearsItsMarker(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles([
'ROLE_TEAMER',
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
])
;
$this->entityManager->expects($this->once())->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertTrue($handler->approveRole($user, 'ROLE_ADMIN'));
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles());
// the other nomination is untouched: one decision at a time
$this->assertSame([User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], $user->getPendingRoles());
}
/**
* @dataProvider unapprovableRoleProvider
*/
public function testApproveRoleRefusesARoleWithoutANomination(array $roles, string $role): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles($roles)
;
$this->entityManager->expects($this->never())->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertFalse($handler->approveRole($user, $role));
$this->assertEqualsCanonicalizing(
$roles,
[...$user->getAssignedRoles(), ...$user->getPendingRoles()],
);
}
public static function unapprovableRoleProvider(): iterable
{
yield 'the CRM never claimed it' => [['ROLE_TEAMER'], 'ROLE_MANAGER'];
yield 'a different role is nominated' => [[User::PENDING_ROLES['ROLE_MANAGER']], 'ROLE_ADMIN'];
yield 'already granted, so there is no marker left' => [['ROLE_ADMIN'], 'ROLE_ADMIN'];
yield 'teamer has no nomination to approve' => [['ROLE_TEAMER'], 'ROLE_TEAMER'];
yield 'not a role at all' => [[User::PENDING_ROLES['ROLE_ADMIN']], 'ROLE_SUPER_ADMIN'];
}
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
{
$user = (new User())
+21
View File
@@ -165,4 +165,25 @@ class UserTest extends TestCase
->validate($user)
;
}
public function testNominatedRolesAreKeyedByTheRoleAndNotByItsMarker(): void
{
$user = (new User())->setRoles([
'ROLE_TEAMER',
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
]);
$this->assertSame(
['ROLE_ADMIN' => 'Admin', 'ROLE_HOUSE_MANAGER' => 'Hausleitung'],
$user->getNominatedRoles(),
);
}
public function testAGrantedRoleIsNotNominated(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
$this->assertSame([], $user->getNominatedRoles());
}
}
+59 -53
View File
@@ -11,88 +11,98 @@ use Symfony\Component\Form\FormFactoryInterface;
class UserTypeTest extends KernelTestCase
{
public function testRendersRolesWithoutSynthesizedRoles(): void
/**
* The form is the account settings and nothing else. Roles and hotel codes are synced
* from BusPro and editable nowhere, so they are not fields at all - a disabled field
* that cannot be submitted only reads as broken.
*/
public function testRolesAndHotelCodesAreNotFields(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setHotelCodes(['DKS'])
;
$form = $this->createForm($user);
$this->assertFalse($form->has('roles'));
$this->assertFalse($form->has('hotelCodes'));
$this->assertFalse($form->has('approvedRoles'));
}
public function testNeitherRolesNorHotelCodesCanBeSubmitted(): void
{
$user = (new User())
->setRoles(['ROLE_TEAMER'])
->setHotelCodes(['SSL'])
;
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'hotelCodes' => ['DKS'],
'disabled' => null,
]);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame(['SSL'], $user->getHotelCodes());
}
/**
* Super admin is an elevation of ROLE_ADMIN, so it is not on offer for anybody else -
* offering it would only ever produce the violation from User::validateSuperAdmin().
*/
public function testSuperAdminIsOnlyOfferedToAnAdmin(): void
{
$this->assertTrue($this->createForm((new User())->setRoles(['ROLE_ADMIN']))->has('superAdmin'));
$this->assertFalse($this->createForm((new User())->setRoles(['ROLE_MANAGER']))->has('superAdmin'));
$this->assertFalse($this->createForm((new User())->setRoles([User::PENDING_ROLES['ROLE_ADMIN']]))->has('superAdmin'));
}
/**
* The exception: a flag left over from before its role was revoked has to stay editable,
* or that account could not be saved at all while the violation stands.
*/
public function testSuperAdminStaysEditableWhenTheFlagOutlivedTheRole(): void
{
$user = (new User())
->setRoles(['ROLE_MANAGER'])
->setSuperAdmin(true)
;
$view = $this->createForm($user)->createView();
// ROLE_USER and ROLE_SUPER_ADMIN are synthesized by getRoles() and must not leak in
$this->assertSame(['ROLE_ADMIN'], $view->children['roles']->vars['data']);
}
public function testApprovingAPendingRoleClearsTheMarker(): void
{
$user = (new User())->setRoles([User::PENDING_ROLES['ROLE_ADMIN']]);
$form = $this->createForm($user);
// the marker is not an assignable choice and must not reach the field
$this->assertSame([], $form->createView()->children['roles']->vars['data']);
$this->assertTrue($form->has('superAdmin'));
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => null,
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
$this->assertFalse($user->isSuperAdmin());
}
public function testRendersHotelCodesNotCoveredByTheConfiguredMap(): void
public function testSuperAdminIsAppointed(): void
{
$user = (new User())->setHotelCodes(['XYZ']);
$view = $this->createForm($user)->createView();
$this->assertSame(['XYZ'], $view->children['hotelCodes']->vars['data']);
}
public function testSubmitStoresAssignedRolesOnly(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user = (new User())->setRoles(['ROLE_ADMIN']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => '1',
'hotelCodes' => [],
'disabled' => null,
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertTrue($user->isSuperAdmin());
}
public function testSubmitRejectsSuperAdminWithoutRoleAdmin(): void
{
$user = new User();
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_MANAGER'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$this->assertFalse($form->isValid());
$this->assertCount(1, $form->get('superAdmin')->getErrors());
}
public function testSubmitBlocksTheAccountWithAReason(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => '1',
'disabledReason' => 'Wegen Fehlverhaltens gesperrt.',
'disabledReasonInternal' => 'Siehe Vorgang 4711.',
@@ -115,9 +125,7 @@ class UserTypeTest extends KernelTestCase
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
'disabled' => null,
'disabledReason' => null,
'disabledReasonInternal' => null,
@@ -143,9 +151,7 @@ class UserTypeTest extends KernelTestCase
// 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.',
+17 -7
View File
@@ -38,7 +38,7 @@ class BpnAuthenticatorTest extends TestCase
{
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn(null);
$this->userDataHandler->expects($this->never())->method('createLocalUser');
@@ -55,7 +55,7 @@ class BpnAuthenticatorTest extends TestCase
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
@@ -69,6 +69,11 @@ class BpnAuthenticatorTest extends TestCase
$this->assertSame($user, $this->loadUser());
}
/**
* This guard is what stands between a degraded response and a mass revocation: with the
* roles led by the CRM, a login that reached updateLocalUser() on an empty payload would
* strip every role of every user logging in, one at a time.
*/
public function testResponseWithoutAttributeGroupsRefusesTheLoginWithoutBlocking(): void
{
$user = (new User())->setRoles(['ROLE_ADMIN']);
@@ -76,14 +81,19 @@ class BpnAuthenticatorTest extends TestCase
// 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('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
$this->expectException(UserNotFoundException::class);
try {
$this->loadUser();
$this->fail('Expected the login to be refused');
} catch (UserNotFoundException) {
}
$this->loadUser();
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
}
/**
@@ -97,7 +107,7 @@ class BpnAuthenticatorTest extends TestCase
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn(['ROLE_TEAMER']);
$this->userDataHandler->method('collectClaimedRoles')->willReturn(['ROLE_TEAMER']);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
@@ -115,7 +125,7 @@ class BpnAuthenticatorTest extends TestCase
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
+30 -3
View File
@@ -110,9 +110,10 @@ class MyEpAuthenticatorTest extends TestCase
}
/**
* A super admin's manual demotion has to survive the user's next SSO login.
* A claimed administrative role is marked for approval, never granted - not even when
* the identity provider reports it outright.
*/
public function testExistingGrantedRolesAreNeverOverwritten(): void
public function testClaimedAdministrativeRolesAreNeverGrantedOnLogin(): void
{
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_TEAMER']);
$this->repository->method('findOneBy')->willReturn($user);
@@ -130,7 +131,7 @@ class MyEpAuthenticatorTest extends TestCase
/**
* An already approved role must not be demoted back to a marker on the next login.
*/
public function testAnAlreadyGrantedAdministrativeRoleIsKept(): void
public function testAnAlreadyGrantedAdministrativeRoleIsKeptWhileStillClaimed(): void
{
$user = (new User())->setEmail('[email protected]')->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER']);
$this->repository->method('findOneBy')->willReturn($user);
@@ -141,6 +142,32 @@ class MyEpAuthenticatorTest extends TestCase
$this->assertSame([], $user->getPendingRoles());
}
/**
* MyE&P leads exactly as BusPro does: a role it stops reporting is withdrawn on the
* next login, and the hotel codes are re-imported with it.
*/
public function testGrantedRolesNoLongerClaimedAreRevoked(): void
{
$user = (new User())
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true)
->setHotelCodes(['SSL'])
;
$this->repository->method('findOneBy')->willReturn($user);
$this->loadUser($this->createUserinfo(['ROLE_TEAMER']));
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
// the flag would otherwise outlive the role it depends on
$this->assertFalse($user->isSuperAdmin());
$this->assertNotContains('ROLE_SUPER_ADMIN', $user->getRoles());
$this->assertSame(['HOTEL'], $user->getHotelCodes());
}
public function testTeamerRoleIsGrantedToAnExistingUser(): void
{
$user = (new User())->setEmail('[email protected]')->setRoles([User::PENDING_ROLES['ROLE_MANAGER']]);