feat: align role assignment logic with myep-team
This commit is contained in:
@@ -11,11 +11,17 @@ use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class CrmAttributesResponseParserTest extends TestCase
|
||||
{
|
||||
private const HOUSE_MANAGER_IDS = [
|
||||
2001 => 'DKS',
|
||||
2002 => 'XYZ',
|
||||
2003 => 'ASB',
|
||||
];
|
||||
|
||||
private CrmAttributesResponseParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new CrmAttributesResponseParser();
|
||||
$this->parser = new CrmAttributesResponseParser(self::HOUSE_MANAGER_IDS);
|
||||
}
|
||||
|
||||
public function testParseAssignsGroupsManagerRoleWhenSelected(): void
|
||||
@@ -38,9 +44,7 @@ class CrmAttributesResponseParserTest extends TestCase
|
||||
{
|
||||
$roles = $this->parseRoles($this->selectionXml(1477, false));
|
||||
|
||||
self::assertNotContains('ROLE_GROUPS_MANAGER', $roles);
|
||||
self::assertNotContains('ROLE_GROUPS_ADMIN', $roles);
|
||||
self::assertSame(['ROLE_CUSTOMER'], $roles);
|
||||
self::assertSame([], $roles, 'the parser reports what BusPro says and adds no fallback');
|
||||
}
|
||||
|
||||
public function testParseStillAssignsExistingAdminManagerTeamerRoles(): void
|
||||
@@ -73,11 +77,25 @@ class CrmAttributesResponseParserTest extends TestCase
|
||||
self::assertSame(['DKS', 'ASB'], $attributes->hotelCodes);
|
||||
}
|
||||
|
||||
public function testParseAddsTheDefaultHotelCodeForAdmins(): void
|
||||
public function testParseIgnoresHausleitungSelectionsThatAreNotMapped(): void
|
||||
{
|
||||
// A house that is deliberately left out of bpn_crm_house_manager_ids claims nothing —
|
||||
// matching is by id, never by label.
|
||||
$parser = new CrmAttributesResponseParser([]);
|
||||
$attributes = $parser->parse((new Crawler($this->hausleitungXml()))->filterXPath('//ergebnis'));
|
||||
|
||||
self::assertSame([], $attributes->roles);
|
||||
self::assertSame([], $attributes->hotelCodes);
|
||||
}
|
||||
|
||||
public function testParseAddsNoDefaultHotelCodeForAdmins(): void
|
||||
{
|
||||
// Hotel codes are synced on every login now, so a default would permanently grant a
|
||||
// house to every administrator.
|
||||
$attributes = $this->parse($this->selectionXml(1292, true));
|
||||
|
||||
self::assertSame(['SSL'], $attributes->hotelCodes);
|
||||
self::assertSame(['ROLE_ADMIN'], $attributes->roles);
|
||||
self::assertSame([], $attributes->hotelCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\User;
|
||||
|
||||
use App\Controller\Admin\User\ApproveRoleController;
|
||||
use App\Entity\User;
|
||||
use App\Security\Role;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Covers the guards around approving a nomination — the only way a role is ever granted here.
|
||||
*/
|
||||
class ApproveRoleControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersTheConfirmationModal(): void
|
||||
{
|
||||
$user = $this->nominatedUser();
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
|
||||
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles()));
|
||||
}
|
||||
|
||||
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
|
||||
{
|
||||
$user = $this->nominatedUser();
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
||||
|
||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
self::assertSame(['success'], array_column($controller->flashes, 'type'));
|
||||
}
|
||||
|
||||
public function testARoleTheCrmNeverClaimedCannotBeApproved(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class));
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
// ROLE_ADMIN is not nominated, so no hand-crafted request can grant it.
|
||||
$controller->index($this->nominatedUser(), Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST'));
|
||||
}
|
||||
|
||||
public function testApprovingRoleAdminForYourOwnAccountIsRefused(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN)]);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), currentUser: $user);
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
|
||||
$controller->index($user, Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST'));
|
||||
}
|
||||
|
||||
public function testApprovingALesserRoleForYourOwnAccountIsAllowed(): void
|
||||
{
|
||||
$user = $this->nominatedUser();
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), currentUser: $user);
|
||||
|
||||
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
|
||||
// rest grant less than they could grant themselves anyway.
|
||||
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
||||
|
||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
|
||||
}
|
||||
|
||||
public function testPostWithAnInvalidTokenIsDenied(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$controller = new TestableApproveRoleController($entityManager, $this->createMock(LoggerInterface::class), tokenValid: false);
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
|
||||
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
||||
}
|
||||
|
||||
public function testApprovingForYourselfReissuesTheSecurityToken(): void
|
||||
{
|
||||
$user = $this->nominatedUser();
|
||||
|
||||
$tokenStorage = new TokenStorage();
|
||||
$tokenStorage->setToken(new PostAuthenticationToken($user, 'main', $user->getRoles()));
|
||||
|
||||
$controller = new TestableApproveRoleController(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
currentUser: $user,
|
||||
tokenStorage: $tokenStorage,
|
||||
);
|
||||
|
||||
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
||||
|
||||
// Without this the next request would find the stored roles out of step with the token
|
||||
// and end the session, logging the approver out mid-action.
|
||||
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []);
|
||||
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []);
|
||||
}
|
||||
|
||||
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
|
||||
{
|
||||
$other = $this->nominatedUser();
|
||||
|
||||
$tokenStorage = new TokenStorage();
|
||||
$admin = (new User('[email protected]'))->setRoles([Role::ADMIN]);
|
||||
$tokenStorage->setToken($originalToken = new PostAuthenticationToken($admin, 'main', $admin->getRoles()));
|
||||
|
||||
$controller = new TestableApproveRoleController(
|
||||
$this->createMock(EntityManagerInterface::class),
|
||||
$this->createMock(LoggerInterface::class),
|
||||
currentUser: $admin,
|
||||
tokenStorage: $tokenStorage,
|
||||
);
|
||||
|
||||
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
||||
|
||||
self::assertSame($originalToken, $tokenStorage->getToken());
|
||||
}
|
||||
|
||||
private function nominatedUser(): User
|
||||
{
|
||||
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableApproveRoleController extends ApproveRoleController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
LoggerInterface $logger,
|
||||
private readonly bool $tokenValid = true,
|
||||
private readonly ?UserInterface $currentUser = null,
|
||||
public readonly TokenStorageInterface $tokenStorage = new TokenStorage(),
|
||||
) {
|
||||
parent::__construct($entityManager, $logger, $this->tokenStorage);
|
||||
}
|
||||
|
||||
protected function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->currentUser;
|
||||
}
|
||||
|
||||
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||
{
|
||||
return $this->tokenValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
public function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\User;
|
||||
|
||||
use App\Controller\Admin\User\ShowController;
|
||||
use App\Entity\User;
|
||||
use App\Security\Role;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class ShowControllerTest extends TestCase
|
||||
{
|
||||
public function testNominationsAreOfferedForApproval(): void
|
||||
{
|
||||
$controller = new TestableShowController();
|
||||
|
||||
$controller->index($this->nominatedUser(), $this->permissionsRequest());
|
||||
|
||||
self::assertSame(
|
||||
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
|
||||
$controller->parameters['approvableRoles'],
|
||||
);
|
||||
self::assertSame([], $controller->parameters['selfRefusedRoles']);
|
||||
}
|
||||
|
||||
public function testWhatCannotBeSelfApprovedIsNotOffered(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
|
||||
|
||||
$controller = new TestableShowController(currentUser: $user);
|
||||
|
||||
$controller->index($user, $this->permissionsRequest());
|
||||
|
||||
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
|
||||
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']);
|
||||
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
|
||||
}
|
||||
|
||||
public function testTheReturnUrlOfTheListIsForwardedUntouched(): void
|
||||
{
|
||||
$controller = new TestableShowController();
|
||||
|
||||
$controller->index($this->nominatedUser(), $this->permissionsRequest());
|
||||
|
||||
// Calling return_url() in the template instead would hand the approval this very modal
|
||||
// and redirect the browser onto a bare modal fragment afterwards.
|
||||
self::assertSame('%2Fadmin%2Fuser%3Fpage%3D2', $controller->parameters['returnUrl']);
|
||||
}
|
||||
|
||||
public function testWithoutAReturnUrlTheListIsUsed(): void
|
||||
{
|
||||
$controller = new TestableShowController();
|
||||
|
||||
$controller->index($this->nominatedUser(), Request::create('/admin/user/7/permissions'));
|
||||
|
||||
self::assertSame(rawurlencode('/app_admin_user'), $controller->parameters['returnUrl']);
|
||||
}
|
||||
|
||||
private function nominatedUser(): User
|
||||
{
|
||||
return (new User('[email protected]'))
|
||||
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list links the modal with r=return_url(), which rawurlencodes the URI, and path()
|
||||
* encodes that again as a query value — so what arrives here is encoded exactly once.
|
||||
*/
|
||||
private function permissionsRequest(): Request
|
||||
{
|
||||
return Request::create('/admin/user/7/permissions?r='.rawurlencode(rawurlencode('/admin/user?page=2')));
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableShowController extends ShowController
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $parameters = [];
|
||||
|
||||
public function __construct(private readonly ?UserInterface $currentUser = null)
|
||||
{
|
||||
}
|
||||
|
||||
protected function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->currentUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->parameters = $parameters;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Admin;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\Admin\UserType;
|
||||
use App\Security\Role;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\Forms;
|
||||
|
||||
class UserTypeTest extends TestCase
|
||||
{
|
||||
public function testOnlyThePrivilegedRolesArePreselected(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
||||
|
||||
self::assertSame([Role::GROUPS_MANAGER], $this->createForm($user)->get('roles')->getData());
|
||||
}
|
||||
|
||||
public function testOnlyPrivilegedRolesAreOffered(): void
|
||||
{
|
||||
$choices = $this->createForm(new User('[email protected]'))->get('roles')->getConfig()->getOption('choices');
|
||||
|
||||
// The rest is synced from BusPro on every login and would be overwritten right away.
|
||||
self::assertSame(Role::PRIVILEGED, array_values($choices));
|
||||
}
|
||||
|
||||
public function testSubmittingRolesKeepsTheSyncedOnesAndNotTheImplicitRoleUser(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
||||
|
||||
$form = $this->createForm($user);
|
||||
$form->submit(['roles' => [Role::GROUPS_MANAGER], 'hotelCodes' => []]);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
// getRoles() prepends ROLE_USER; it must not have been persisted a second time.
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testClearingEveryCheckboxKeepsTheSyncedRoles(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))
|
||||
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
||||
->setHotelCodes(['SSL'])
|
||||
;
|
||||
|
||||
$form = $this->createForm($user);
|
||||
$form->submit([]);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
|
||||
self::assertSame([], $user->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testHotelCodeMissingFromTheCatalogSurvivesAnEdit(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setHotelCodes(['SSL', 'XYZ']);
|
||||
|
||||
$form = $this->createForm($user);
|
||||
$form->submit(['roles' => [], 'hotelCodes' => ['SSL', 'XYZ']]);
|
||||
|
||||
self::assertTrue($form->isSynchronized());
|
||||
self::assertSame(['SSL', 'XYZ'], $user->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testHotelCodesAreOfferedAlphabetically(): void
|
||||
{
|
||||
$user = (new User('[email protected]'))->setHotelCodes(['ASB']);
|
||||
|
||||
$choices = $this->createForm($user)->get('hotelCodes')->getConfig()->getOption('choices');
|
||||
|
||||
self::assertSame(['ASB', 'DKS', 'SSL'], array_values($choices));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<User>
|
||||
*/
|
||||
private function createForm(User $user): FormInterface
|
||||
{
|
||||
return Forms::createFormFactoryBuilder()
|
||||
->addType(new UserType(['SSL' => 'SSL', 'DKS' => 'DKS']))
|
||||
->getFormFactory()
|
||||
->create(UserType::class, $user)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ namespace App\Tests\Security;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\CrmSelectionGroup;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Security\BpnAuthenticator;
|
||||
use App\Security\Crypt;
|
||||
use App\Security\DefaultRouteResolver;
|
||||
use App\Security\Role;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -22,16 +22,16 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
/**
|
||||
* Covers who may grant roles: BusPro backend users can edit their own CRM selections, so the
|
||||
* import must not be a channel for privilege escalation.
|
||||
* Covers what a login does to an account: BusPro owns the roles and the hotel codes, but a
|
||||
* CRM claim must never grant an administrative role on its own.
|
||||
*/
|
||||
class BpnAuthenticatorTest extends TestCase
|
||||
{
|
||||
public function testNewAccountIsSeededWithTheImportableRolesOnly(): void
|
||||
public function testNewAccountIsSeededFromTheCrm(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([Role::ADMIN, Role::TEAMER, Role::GROUPS_ADMIN], ['SSL', 'SSL']),
|
||||
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
|
||||
null,
|
||||
$persisted,
|
||||
);
|
||||
@@ -39,20 +39,17 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$user = $this->loadUser($authenticator);
|
||||
|
||||
self::assertSame($persisted, $user);
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
|
||||
self::assertSame(['SSL'], $user->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testExistingAccountKeepsThePrivilegedRolesAnAdministratorAssigned(): void
|
||||
public function testAdministrativeClaimIsOnlyANominationUntilItIsApproved(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))
|
||||
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
||||
->setHotelCodes(['DKS'])
|
||||
;
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
||||
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([Role::ADMIN, Role::CUSTOMER], ['SSL']),
|
||||
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
|
||||
$existing,
|
||||
$persisted,
|
||||
);
|
||||
@@ -60,13 +57,42 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$user = $this->loadUser($authenticator);
|
||||
|
||||
self::assertNull($persisted, 'an existing account must not be persisted again');
|
||||
// ROLE_TEAMER is gone with its CRM selection, ROLE_ADMIN is still not honoured, and the
|
||||
// administrator-granted ROLE_GROUPS_MANAGER survives.
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER, Role::GROUPS_MANAGER], $user->getRoles());
|
||||
self::assertSame(['DKS'], $user->getHotelCodes(), 'hotel codes stay administrator-managed');
|
||||
self::assertSame(
|
||||
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
|
||||
$user->getRoles(),
|
||||
);
|
||||
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
|
||||
}
|
||||
|
||||
public function testApprovedRoleSurvivesTheNextLogin(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
||||
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
|
||||
$existing,
|
||||
$persisted,
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
|
||||
$this->loadUser($authenticator)->getRoles(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
||||
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
||||
|
||||
// Nothing is claimed any more, so nothing is held — and an account without an effective
|
||||
// role is a customer.
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
|
||||
}
|
||||
|
||||
public function testRoleGainedInBusProIsGrantedOnLogin(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::CUSTOMER]);
|
||||
@@ -82,29 +108,69 @@ class BpnAuthenticatorTest extends TestCase
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles());
|
||||
}
|
||||
|
||||
public function testAccountWithoutAnyRoleIsHealedOnLogin(): void
|
||||
public function testHotelCodesAreResyncedOnEveryLogin(): void
|
||||
{
|
||||
$existing = new User('teamer@example.org');
|
||||
$existing = (new User('house@example.org'))
|
||||
->setRoles([Role::HOUSE_MANAGER])
|
||||
->setHotelCodes(['DKS', 'SSL'])
|
||||
;
|
||||
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([Role::TEAMER], []),
|
||||
$this->crmAttributes([Role::HOUSE_MANAGER], ['DKS']),
|
||||
$existing,
|
||||
$persisted,
|
||||
);
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles());
|
||||
self::assertSame(['DKS'], $this->loadUser($authenticator)->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))
|
||||
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
||||
->setHotelCodes(['DKS'])
|
||||
;
|
||||
|
||||
$persisted = null;
|
||||
// No selection groups at all: BusPro always answers with the full attribute tree, so
|
||||
// this is a degraded payload and not a revocation of everything.
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([], [], selectionGroups: []),
|
||||
$existing,
|
||||
$persisted,
|
||||
);
|
||||
|
||||
$user = $this->loadUser($authenticator);
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
||||
self::assertSame(['DKS'], $user->getHotelCodes());
|
||||
}
|
||||
|
||||
public function testDegradedCrmResponseStillGivesANewAccountTheFallbackRole(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator(
|
||||
$this->crmAttributes([], [], selectionGroups: []),
|
||||
null,
|
||||
$persisted,
|
||||
);
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
* @param string[] $hotelCodes
|
||||
* @param string[] $roles
|
||||
* @param string[] $hotelCodes
|
||||
* @param CrmSelectionGroup[] $selectionGroups only their presence matters here — an empty
|
||||
* set is what marks a response as degraded
|
||||
*/
|
||||
private function crmAttributes(array $roles, array $hotelCodes): CrmAttributes
|
||||
private function crmAttributes(array $roles, array $hotelCodes, ?array $selectionGroups = null): CrmAttributes
|
||||
{
|
||||
$attributes = new CrmAttributes();
|
||||
$attributes->roles = $roles;
|
||||
$attributes->hotelCodes = $hotelCodes;
|
||||
$attributes->selectionGroups = $selectionGroups ?? [new CrmSelectionGroup()];
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
@@ -144,7 +210,6 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$crypt,
|
||||
$completenessChecker,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
$this->createMock(DefaultRouteResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+73
-33
@@ -7,62 +7,102 @@ namespace App\Tests\Security;
|
||||
use App\Security\Role;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Covers the role policy: BusPro backend users can edit their own CRM selections, so a claim
|
||||
* must never grant an administrative role on its own.
|
||||
*/
|
||||
class RoleTest extends TestCase
|
||||
{
|
||||
public function testPrivilegedRolesAreNeverImported(): void
|
||||
public function testAdministrativeClaimOnlyProducesANomination(): void
|
||||
{
|
||||
$roles = Role::filterImportable([
|
||||
Role::TEAMER,
|
||||
Role::ADMIN,
|
||||
Role::GROUPS_ADMIN,
|
||||
Role::GROUPS_MANAGER,
|
||||
Role::HOUSE_MANAGER,
|
||||
]);
|
||||
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
|
||||
|
||||
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
|
||||
self::assertSame(
|
||||
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
|
||||
$roles,
|
||||
);
|
||||
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
||||
}
|
||||
|
||||
public function testResultIsADedupedList(): void
|
||||
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
||||
{
|
||||
// CrmAttributesResponseParser applies array_unique(), which preserves keys — a
|
||||
// non-list would be persisted as a JSON object instead of an array.
|
||||
$roles = Role::filterImportable([0 => Role::ADMIN, 2 => Role::TEAMER, 5 => Role::TEAMER]);
|
||||
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
|
||||
|
||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
|
||||
}
|
||||
|
||||
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
|
||||
{
|
||||
// Both halves go: BusPro is the source of truth for the granted role as much as for
|
||||
// the nomination.
|
||||
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]);
|
||||
|
||||
self::assertSame([Role::TEAMER], $roles);
|
||||
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
|
||||
}
|
||||
|
||||
public function testAccountWithOnlyPrivilegedRolesFallsBackToCustomer(): void
|
||||
public function testRevokedRoleIsNotImmediatelyNominatedAgain(): void
|
||||
{
|
||||
self::assertSame([Role::CUSTOMER], Role::filterImportable([Role::ADMIN]));
|
||||
self::assertSame([Role::CUSTOMER], Role::filterImportable([]));
|
||||
self::assertSame([Role::CUSTOMER], Role::sync([Role::ADMIN], []));
|
||||
}
|
||||
|
||||
public function testAssignedOnlyDropsTheImplicitRoleUser(): void
|
||||
public function testAccountWithoutAnEffectiveRoleFallsBackToCustomer(): void
|
||||
{
|
||||
$roles = Role::assignedOnly([Role::USER, Role::TEAMER, Role::GROUPS_ADMIN]);
|
||||
|
||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
|
||||
// The value is JSON-encoded into the userinfo response and must not become an object.
|
||||
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
|
||||
// The nomination stays visible — it is what an approver acts on — but grants nothing,
|
||||
// so the account is a customer in the meantime.
|
||||
self::assertSame(
|
||||
[Role::pending(Role::ADMIN), Role::CUSTOMER],
|
||||
Role::sync([], [Role::ADMIN]),
|
||||
);
|
||||
self::assertSame([Role::CUSTOMER], Role::sync([], []));
|
||||
}
|
||||
|
||||
public function testCombineKeepsEachHalfInItsOwnLane(): void
|
||||
public function testCustomerIsAFallbackAndNotABaseline(): void
|
||||
{
|
||||
$roles = Role::combine([Role::TEAMER, Role::ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
|
||||
|
||||
// The ADMIN from the synced half and the TEAMER from the privileged half are discarded.
|
||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
|
||||
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
|
||||
self::assertSame([Role::TEAMER], Role::sync([Role::CUSTOMER], [Role::TEAMER]));
|
||||
}
|
||||
|
||||
public function testCombineDropsTheImplicitRoleUser(): void
|
||||
public function testUnknownClaimsAndTheImplicitRoleUserAreIgnored(): void
|
||||
{
|
||||
self::assertSame([Role::TEAMER], Role::combine([Role::USER, Role::TEAMER], []));
|
||||
self::assertSame(
|
||||
[Role::TEAMER],
|
||||
Role::sync([Role::USER, Role::TEAMER], [Role::TEAMER, 'ROLE_SOMETHING_ELSE']),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryRoleHasALabel(): void
|
||||
public function testApprovalTurnsTheNominationIntoTheRole(): void
|
||||
{
|
||||
self::assertSame(Role::ALL, array_keys(Role::labels()));
|
||||
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
|
||||
|
||||
// The customer fallback goes with it: the account now holds an effective role.
|
||||
self::assertSame([Role::ADMIN], $roles);
|
||||
}
|
||||
|
||||
public function testApprovingARoleWithoutANominationIsRefused(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
Role::approve([Role::TEAMER], Role::ADMIN);
|
||||
}
|
||||
|
||||
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
|
||||
{
|
||||
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
|
||||
|
||||
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
||||
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
|
||||
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
|
||||
}
|
||||
|
||||
public function testEveryRoleAndNominationHasALabel(): void
|
||||
{
|
||||
$labels = Role::labels();
|
||||
|
||||
foreach (Role::ALL as $role) {
|
||||
self::assertArrayHasKey($role, $labels);
|
||||
}
|
||||
|
||||
foreach (Role::ADMINISTRATIVE as $role) {
|
||||
self::assertArrayHasKey(Role::pending($role), $labels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Twig;
|
||||
|
||||
use App\Security\Role;
|
||||
use App\Twig\AppRuntime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppRuntimeMapRolesTest extends TestCase
|
||||
{
|
||||
public function testStoredRolesAreLabelledLikeInTheEditForm(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['Teamer:in', 'Preisrechner'],
|
||||
$this->runtime()->mapRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testImplicitRoleUserIsNotListed(): void
|
||||
{
|
||||
self::assertSame(['Administration'], $this->runtime()->mapRoles(['ROLE_USER', Role::ADMIN]));
|
||||
self::assertSame([], $this->runtime()->mapRoles(['ROLE_USER']));
|
||||
}
|
||||
|
||||
public function testUnknownRoleStaysVisible(): void
|
||||
{
|
||||
self::assertSame(['ROLE_LEGACY'], $this->runtime()->mapRoles(['ROLE_LEGACY']));
|
||||
}
|
||||
|
||||
private function runtime(): AppRuntime
|
||||
{
|
||||
return (new \ReflectionClass(AppRuntime::class))->newInstanceWithoutConstructor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Twig;
|
||||
|
||||
use App\Security\Role;
|
||||
use App\Twig\AppRuntime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppRuntimeRoleLabelsTest extends TestCase
|
||||
{
|
||||
public function testEffectiveRolesAreLabelled(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['Teamer:in', 'Preisrechner'],
|
||||
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testImplicitRoleUserIsNotListed(): void
|
||||
{
|
||||
self::assertSame(['Administration'], $this->runtime()->effectiveRoles([Role::USER, Role::ADMIN]));
|
||||
self::assertSame([], $this->runtime()->effectiveRoles([Role::USER]));
|
||||
}
|
||||
|
||||
public function testNominationsAreListedApartFromTheEffectiveRoles(): void
|
||||
{
|
||||
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
|
||||
|
||||
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
|
||||
self::assertSame(['Administration'], $this->runtime()->nominatedRoles($roles));
|
||||
}
|
||||
|
||||
public function testUnknownRoleStaysVisible(): void
|
||||
{
|
||||
self::assertSame(['ROLE_LEGACY'], $this->runtime()->effectiveRoles(['ROLE_LEGACY']));
|
||||
}
|
||||
|
||||
private function runtime(): AppRuntime
|
||||
{
|
||||
return (new \ReflectionClass(AppRuntime::class))->newInstanceWithoutConstructor();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user