feat: pending roles from crm to be confirmed by superadmins

This commit is contained in:
Björn Fromme
2026-08-10 12:26:21 +02:00
parent 681734533f
commit b01c2e84c7
26 changed files with 907 additions and 150 deletions
+128 -4
View File
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
use App\BusProNet\Model\Address as BusProAddress;
use App\BusProNet\Model\Communication as BusProCommunication;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\UserDataHandler;
use App\Entity\Embeddable\Address;
@@ -29,6 +30,59 @@ class UserDataHandlerTest extends TestCase
$this->logger = $this->createMock(LoggerInterface::class);
}
/**
* @dataProvider collectRolesProvider
*/
public function testCollectRolesImportsAdministrativeRolesAsPendingOnly(CrmAttributesResponse $crmAttributes, array $expectedRoles): void
{
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertSame($expectedRoles, $handler->collectRoles($crmAttributes));
}
public static function collectRolesProvider(): iterable
{
yield 'admin only yields the pending marker' => [
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'manager only yields the pending marker' => [
(new CrmAttributesResponse())->setManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager only yields the pending marker' => [
(new CrmAttributesResponse())->setHouseManager(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'teamer is granted directly' => [
(new CrmAttributesResponse())->setTeamer(true),
['ROLE_TEAMER'],
];
yield 'admin and teamer' => [
(new CrmAttributesResponse())->setAdmin(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'],
];
yield 'admin and manager yield both markers' => [
(new CrmAttributesResponse())->setAdmin(true)->setManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'manager takes precedence over house manager' => [
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager and teamer' => [
(new CrmAttributesResponse())->setHouseManager(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
];
}
public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void
{
$user = (new User())
@@ -37,6 +91,8 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
->setRoles(['ROLE_ADMIN'])
->setHotelCodes(['XYZ'])
;
$teamer = (new Teamer())
@@ -61,17 +117,17 @@ class UserDataHandlerTest extends TestCase
$handler->updateLocalUser(
$user,
$profileResponse,
['ROLE_TEAMER'],
true,
['team' => ['selected' => true]],
['ABC'],
);
$this->assertSame('New', $user->getFirstName());
$this->assertSame('Lastname', $user->getLastName());
$this->assertSame('[email protected]', $user->getEmail());
$this->assertSame(['ABC'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_TEAMER'));
// roles and hotel codes are imported on creation only and stay under manual control
$this->assertSame(['XYZ'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN'));
$this->assertSame('New', $teamer->getFirstName());
$this->assertSame('Lastname', $teamer->getLastName());
@@ -89,6 +145,74 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections());
}
/**
* @dataProvider pendingRolesProvider
*/
public function testUpdateLocalUserRefreshesThePendingRoles(
array $roles,
array $claimedRoles,
array $expectedAssignedRoles,
array $expectedPendingRoles,
): void {
$user = (new User())
->setFirstName('Old')
->setLastName('Name')
->setEmail('[email protected]')
->setRoles($roles)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], $claimedRoles);
$this->assertSame($expectedAssignedRoles, $user->getAssignedRoles());
$this->assertSame($expectedPendingRoles, $user->getPendingRoles());
}
public static function pendingRolesProvider(): iterable
{
yield 'marker is added when the CRM claims a manager' => [
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['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'],
[],
];
yield 'an approved role is never marked again' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['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_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'the claimed role changes' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER']],
[],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'granted roles are untouched without any claim' => [
['ROLE_ADMIN'],
[],
['ROLE_ADMIN'],
[],
];
}
public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void
{
$user = (new User())
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\HouseCatalog;
use PHPUnit\Framework\TestCase;
class HouseCatalogTest extends TestCase
{
private const HOUSES = [
'SVS' => 'Silvana',
'AGR' => 'Rotbach',
'DPW' => 'Waldschlössli',
];
public function testNameChoicesAreSortedAndMapLabelToName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'Rotbach',
'Silvana' => 'Silvana',
'Waldschlössli' => 'Waldschlössli',
], $catalog->getNameChoices());
}
public function testCodeChoicesAreSortedByLabelAndMapLabelToCode(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'AGR',
'Silvana' => 'SVS',
'Waldschlössli' => 'DPW',
], $catalog->getCodeChoices());
}
public function testGetName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame('Silvana', $catalog->getName('SVS'));
$this->assertNull($catalog->getName('XYZ'));
}
public function testEmptyCatalog(): void
{
$catalog = new HouseCatalog([]);
$this->assertSame([], $catalog->getNameChoices());
$this->assertSame([], $catalog->getCodeChoices());
}
}
+62
View File
@@ -6,6 +6,8 @@ namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
class UserTest extends TestCase
{
@@ -23,4 +25,64 @@ class UserTest extends TestCase
$isMatch = $user->hasHotelCodeMatch('XXDEFXX');
$this->assertFalse($isMatch);
}
public function testPendingMarkersAreLabelledButNotAssignable(): void
{
$user = (new User())->setRoles([
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
'ROLE_TEAMER',
]);
$this->assertSame(
['Admin (nicht freigeschaltet)', 'Hausleitung (nicht freigeschaltet)', 'Teamer'],
$user->getRolesLabels(),
);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame(
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
$user->getPendingRoles(),
);
}
public function testSuperAdminRequiresRoleAdmin(): void
{
$user = (new User())
->setRoles(['ROLE_MANAGER'])
->setSuperAdmin(true)
;
$violations = $this->validate($user);
$this->assertCount(1, $violations);
$this->assertSame('superAdmin', $violations[0]->getPropertyPath());
}
public function testSuperAdminWithRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin(true)
;
$this->assertCount(0, $this->validate($user));
}
public function testNonSuperAdminWithoutRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_TEAMER'])
;
$this->assertCount(0, $this->validate($user));
}
private function validate(User $user): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
->validate($user)
;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\User;
use App\Form\UserType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
class UserTypeTest extends KernelTestCase
{
public function testRendersRolesWithoutSynthesizedRoles(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->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']);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
}
public function testRendersHotelCodesNotCoveredByTheConfiguredMap(): 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']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$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());
}
private function createForm(User $user): \Symfony\Component\Form\FormInterface
{
self::bootKernel();
/** @var FormFactoryInterface $formFactory */
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
return $formFactory->create(UserType::class, $user, [
'csrf_protection' => false,
]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\User;
use App\Security\Voter\UserVoter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class UserVoterTest extends TestCase
{
private Security&MockObject $security;
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
}
public function testSuperAdminMayEditOtherUsers(): void
{
$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->vote($this->createUser(true), $this->createUser(false)),
);
}
public function testPlainAdminMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(false), $this->createUser(false)),
);
}
public function testSuperAdminMayNotEditThemselves(): void
{
$currentUser = $this->createUser(true);
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($currentUser, $currentUser),
);
}
public function testImpersonatorMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(true), $this->createUser(false), true),
);
}
private function vote(User $currentUser, User $targetUser, bool $isImpersonator = false): int
{
$this->security
->method('isGranted')
->with('IS_IMPERSONATOR')
->willReturn($isImpersonator);
$token = $this->createMock(TokenInterface::class);
$token
->method('getUser')
->willReturn($currentUser);
$voter = new UserVoter($this->security);
return $voter->vote($token, $targetUser, [UserVoter::EDIT]);
}
private function createUser(bool $superAdmin): User
{
return (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin($superAdmin)
;
}
}