diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b654b14..f3e2152 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -17,9 +17,6 @@ security: roles: [ ROLE_MAILJET_WEBHOOK ] role_hierarchy: - ROLE_ADMIN: - - ROLE_GROUPS_ADMIN - - ROLE_HOUSE_MANAGER ROLE_GROUPS_ADMIN: - ROLE_GROUPS_MANAGER - diff --git a/config/services.yaml b/config/services.yaml index 5735cb3..8ac3c8a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,6 +30,31 @@ parameters: 10321389: 'Reisen-Alert Stubaital' 10554990: 'Reisen-Alert Ski & Boarderweek' + # Hausleitung hotel codes and their labels, assignable to users in /admin/user. + # Mirrors the "Hausleitung {CODE}" CRM selections published by BusProNet. + hotel_codes: + SSL: 'SSL' + SST: 'SST' + MVK: 'MVK' + ASB: 'ASB' + LPJ: 'LPJ' + DKS: 'DKS' + DGS: 'DGS' + DPW: 'DPW' + DKI: 'DKI' + DWW: 'DWW' + PMV: 'PMV' + ASG: 'ASG' + ASC: 'ASC' + SBW: 'SBW' + UCH: 'UCH' + SZO: 'SZO' + PCJ: 'PCJ' + KHH: 'KHH' + SVS: 'SVS' + SHM: 'SHM' + AGR: 'AGR' + # MailJet contact metadata names for name synchronization mailjet_contact_metadata_fields: firstName: 'vorname' @@ -253,6 +278,10 @@ services: arguments: $mailjetLists: '%mailjet_lists%' + App\Form\Admin\UserType: + arguments: + $hotelCodes: '%hotel_codes%' + App\Service\DomainConfigProvider: arguments: $domainConfig: '%domain_config%' diff --git a/src/Controller/Admin/User/EditController.php b/src/Controller/Admin/User/EditController.php new file mode 100644 index 0000000..90b8f43 --- /dev/null +++ b/src/Controller/Admin/User/EditController.php @@ -0,0 +1,76 @@ +getRoles(); + $previousHotelCodes = $user->getHotelCodes(); + + $form = $this->createForm(UserType::class, $user, ['hx_post' => $request->getRequestUri()]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if ($this->locksOutSelf($user)) { + $form->get('roles')->addError(new FormError('Du kannst dir die Rolle Administration nicht selbst entziehen.')); + + return $this->render('admin/user/modal_edit.html.twig', [ + 'user' => $user, + 'form' => $form, + ]); + } + + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Benutzeraccount wurde aktualisiert'); + + $this->logger->info('Updated user permissions', [ + 'email' => $user->getEmail(), + 'previousRoles' => $previousRoles, + 'roles' => $user->getRoles(), + 'previousHotelCodes' => $previousHotelCodes, + 'hotelCodes' => $user->getHotelCodes(), + ]); + + return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user')); + } + + return $this->render('admin/user/modal_edit.html.twig', [ + 'user' => $user, + 'form' => $form, + ]); + } + + private function locksOutSelf(User $user): bool + { + return $user === $this->getUser() && false === \in_array(Role::ADMIN, $user->getRoles(), true); + } +} diff --git a/src/Controller/Admin/User/FilterController.php b/src/Controller/Admin/User/FilterController.php index c466f47..0da5558 100644 --- a/src/Controller/Admin/User/FilterController.php +++ b/src/Controller/Admin/User/FilterController.php @@ -26,7 +26,6 @@ class FilterController extends AbstractController UserFilterType::class, new UserFilterDto(), 'app_admin_user', - ['roles' => UserFilterType::ROLES], ); return $this->render('admin/_modal_filter.html.twig', [ diff --git a/src/Controller/Admin/User/IndexController.php b/src/Controller/Admin/User/IndexController.php index 9f56476..7942d8b 100644 --- a/src/Controller/Admin/User/IndexController.php +++ b/src/Controller/Admin/User/IndexController.php @@ -36,7 +36,6 @@ class IndexController extends AbstractController UserFilterType::class, $filter, 'app_admin_user', - ['roles' => UserFilterType::ROLES], ); $pagination = $this->paginator->paginate( diff --git a/src/Form/Admin/Filter/UserFilterType.php b/src/Form/Admin/Filter/UserFilterType.php index 76eac31..4cb9d1f 100644 --- a/src/Form/Admin/Filter/UserFilterType.php +++ b/src/Form/Admin/Filter/UserFilterType.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Form\Admin\Filter; use App\Form\Model\Filter\UserFilterDto; +use App\Security\Role; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -13,28 +14,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver; */ class UserFilterType extends AbstractListFilterType { - /** - * The roles that are actually assigned to accounts. ROLE_USER is left out because every - * account has it implicitly and filtering by it would match everything. - * - * @var string[] - */ - public const ROLES = [ - 'ROLE_ADMIN', - 'ROLE_MANAGER', - 'ROLE_TEAMER', - 'ROLE_CUSTOMER', - 'ROLE_HOUSE_MANAGER', - 'ROLE_GROUPS_ADMIN', - 'ROLE_GROUPS_MANAGER', - ]; - protected function filterFields(array $options): array { return [ 'role' => [ChoiceType::class, [ 'label' => 'Rolle', - 'choices' => array_combine($options['roles'], $options['roles']), + 'choices' => array_flip(Role::labels()), 'placeholder' => 'alle', 'required' => false, ]], @@ -47,9 +32,7 @@ class UserFilterType extends AbstractListFilterType $resolver->setDefaults([ 'data_class' => UserFilterDto::class, - 'roles' => [], ]); - $resolver->setAllowedTypes('roles', 'string[]'); } protected function searchPlaceholder(): string diff --git a/src/Form/Admin/UserType.php b/src/Form/Admin/UserType.php new file mode 100644 index 0000000..8cf0b53 --- /dev/null +++ b/src/Form/Admin/UserType.php @@ -0,0 +1,86 @@ + + */ +class UserType extends AbstractType +{ + /** + * @param array $hotelCodes code => label + */ + public function __construct(private readonly array $hotelCodes = []) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $user = $builder->getData(); + + $builder + ->add('roles', ChoiceType::class, [ + 'label' => 'Rollen', + 'choices' => array_flip(Role::labels()), + 'multiple' => true, + 'expanded' => true, + 'required' => false, + // User::getRoles() prepends the implicit ROLE_USER, which must not be written back. + 'getter' => static fn (User $user): array => array_values(array_diff($user->getRoles(), ['ROLE_USER'])), + 'setter' => static function (User $user, array $roles): void { + $user->setRoles(array_values(array_unique($roles))); + }, + ]) + ->add('hotelCodes', ChoiceType::class, [ + 'label' => 'Häuser', + 'choices' => $this->hotelCodeChoices($user), + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'setter' => static function (User $user, array $hotelCodes): void { + $user->setHotelCodes(array_values(array_unique($hotelCodes))); + }, + ]) + ; + } + + /** + * Codes already stored on the account are always offered, even when they are missing from + * the configured catalog — otherwise saving the form would silently drop them. + * + * @return array label => code + */ + private function hotelCodeChoices(?User $user): array + { + $codes = $this->hotelCodes; + + foreach ($user?->getHotelCodes() ?? [] as $code) { + $codes[$code] ??= $code; + } + + ksort($codes); + + return array_flip($codes); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Form/Model/Filter/UserFilterDto.php b/src/Form/Model/Filter/UserFilterDto.php index 94e405f..cf75c86 100644 --- a/src/Form/Model/Filter/UserFilterDto.php +++ b/src/Form/Model/Filter/UserFilterDto.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Form\Model\Filter; use App\Model\ListFilterChip; +use App\Security\Role; class UserFilterDto extends AbstractListFilterDto { @@ -15,7 +16,7 @@ class UserFilterDto extends AbstractListFilterDto $chips = parent::activeFilters(); if (null !== $this->role && '' !== $this->role) { - $chips[] = new ListFilterChip('Rolle', $this->role, ['role']); + $chips[] = new ListFilterChip('Rolle', Role::labels()[$this->role] ?? $this->role, ['role']); } return $chips; diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 9d62d82..2207705 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -31,9 +31,13 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait; /** * Authenticates users against the BPN API. * - * Validates credentials via BPN's getPersonalData endpoint, creates or updates - * local User entities, and retrieves CRM attributes (roles, hotel codes) for - * authorization. Passwords are stored encrypted with RSA for subsequent API calls. + * Validates credentials via BPN's getPersonalData endpoint and creates or updates + * local User entities. Passwords are stored encrypted with RSA for subsequent API calls. + * + * CRM attributes (roles, hotel codes) seed a *new* account only: BusPro backend users can + * edit their own CRM selections, so taking roles over on every login would let anybody grant + * themselves Role::PRIVILEGED here. Privileged roles are never imported at all, and from the + * second login on both roles and hotel codes are managed by an administrator in /admin/user. */ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface { @@ -96,15 +100,17 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent throw new CustomUserMessageAuthenticationException($message); } - $roles = $crmAttributes->roles; - $hotelCodes = $crmAttributes->hotelCodes; - $encryptedPassword = $this->crypt->encrypt($password); $userRepository = $this->entityManager->getRepository(User::class); if (null === $user = $userRepository->findOneBy(['email' => $email])) { $user = new User($email); + $user + ->setRoles($this->importableRoles($email, $crmAttributes->roles)) + ->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes))) + ; + $this->entityManager->persist($user); } @@ -112,8 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent ->setPassword($encryptedPassword) ->setPersonId($personalData->personId) ->setAddressId($personalData->addressId) - ->setRoles($roles) - ->setHotelCodes($hotelCodes) ->setLastLoginAt(new \DateTimeImmutable()) ->setProfileComplete($this->completenessChecker->isComplete($personalData)) ; @@ -123,6 +127,28 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent return $user; } + /** + * @param string[] $crmRoles + * + * @return string[] + */ + private function importableRoles(string $email, array $crmRoles): array + { + $roles = Role::filterImportable($crmRoles); + $dropped = array_values(array_intersect($crmRoles, Role::PRIVILEGED)); + + if ([] !== $dropped) { + // Somebody holds a privileged CRM selection in BusPro. We do not honour it, but it + // should stay visible: it either needs to be revoked there or granted in /admin/user. + $this->authLogger->warning('Ignored privileged roles from BPN CRM attributes', [ + 'email' => $email, + 'roles' => $dropped, + ]); + } + + return $roles; + } + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { $this->authLogger->info('Login', [ diff --git a/src/Security/Role.php b/src/Security/Role.php new file mode 100644 index 0000000..4d0b3c7 --- /dev/null +++ b/src/Security/Role.php @@ -0,0 +1,84 @@ + role => label + */ + public static function labels(): array + { + return [ + self::ADMIN => 'Administration', + self::MANAGER => 'Manager:in', + self::TEAMER => 'Teamer:in', + self::CUSTOMER => 'Kund:in', + self::HOUSE_MANAGER => 'Hausleitung', + self::GROUPS_ADMIN => 'Preisrechner Admin', + self::GROUPS_MANAGER => 'Preisrechner', + ]; + } +} diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 3104af8..0b4f2d3 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -27,6 +27,7 @@ class AppExtension extends AbstractExtension new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']), new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']), new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']), + new TwigFilter('map_roles', [AppRuntime::class, 'mapRoles']), ]; } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 249f493..146794d 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -12,6 +12,7 @@ use App\Form\Service\Condition\TravelStartCutoffReachedCondition; use App\Form\Service\CreateFieldStateProvider; use App\Form\Service\EditFieldStateProvider; use App\Model\DomainConfig; +use App\Security\Role; use App\Service\ParticipantEligibilityChecker; use Symfony\Component\Form\FormView; use Symfony\Component\HttpFoundation\RequestStack; @@ -124,6 +125,33 @@ class AppRuntime implements RuntimeExtensionInterface return $this->countryDataProvider->get($nationality)?->nationality; } + /** + * Turns the roles stored on a User into the labels the edit form uses. + * + * ROLE_USER is dropped because every account holds it implicitly; a role without a label + * is passed through unchanged so it stays visible instead of silently disappearing. + * + * @param string[] $roles + * + * @return string[] + */ + public function mapRoles(array $roles): array + { + $labels = Role::labels(); + + $mapped = []; + + foreach ($roles as $role) { + if ('ROLE_USER' === $role) { + continue; + } + + $mapped[] = $labels[$role] ?? $role; + } + + return $mapped; + } + public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { return $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex); diff --git a/templates/admin/user/index.html.twig b/templates/admin/user/index.html.twig index d72b041..bbbc361 100644 --- a/templates/admin/user/index.html.twig +++ b/templates/admin/user/index.html.twig @@ -22,9 +22,16 @@ {{ knp_pagination_sortable(pagination, 'BusPro Personen Id', 'user.personId') }} + + Rollen + + + Häuser + {{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }} + @@ -39,13 +46,26 @@ {{ user.personId | default('-') }} + + {{ user.roles | map_roles | join(', ') | default('-') }} + + + {{ user.hotelCodes | join(', ') | default('-') }} + {{ user.lastLoginAt | date('d.m.Y, H:i') }} + + + + Berechtigungen bearbeiten + + + {% else %} - + {{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }} diff --git a/templates/admin/user/modal_edit.html.twig b/templates/admin/user/modal_edit.html.twig new file mode 100644 index 0000000..8e3e7d8 --- /dev/null +++ b/templates/admin/user/modal_edit.html.twig @@ -0,0 +1,24 @@ +{% extends 'htmx_modal_admin.html.twig' %} +{% form_theme form 'forms_admin.html.twig' %} + +{% block title %} + Berechtigungen +{% endblock %} + +{% block content %} +
+ {{ user.email }} +
+ {{ form_start(form) }} +
+ {{ form_row(form.roles) }} + {{ form_row(form.hotelCodes) }} +
+
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Form/Admin/UserTypeTest.php b/tests/Form/Admin/UserTypeTest.php new file mode 100644 index 0000000..da491a9 --- /dev/null +++ b/tests/Form/Admin/UserTypeTest.php @@ -0,0 +1,81 @@ +setRoles([Role::TEAMER]); + + self::assertSame([Role::TEAMER], $this->createForm($user)->get('roles')->getData()); + } + + public function testSubmittingRolesDoesNotStoreTheImplicitRoleUser(): void + { + $user = (new User('teamer@example.org'))->setRoles([Role::TEAMER]); + + $form = $this->createForm($user); + $form->submit(['roles' => [Role::TEAMER, 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 testClearingEveryCheckboxEmptiesTheAssignment(): void + { + $user = (new User('teamer@example.org')) + ->setRoles([Role::TEAMER]) + ->setHotelCodes(['SSL']) + ; + + $form = $this->createForm($user); + $form->submit([]); + + self::assertTrue($form->isSynchronized()); + self::assertSame(['ROLE_USER'], $user->getRoles()); + self::assertSame([], $user->getHotelCodes()); + } + + public function testHotelCodeMissingFromTheCatalogSurvivesAnEdit(): void + { + $user = (new User('house@example.org'))->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('house@example.org'))->setHotelCodes(['ASB']); + + $choices = $this->createForm($user)->get('hotelCodes')->getConfig()->getOption('choices'); + + self::assertSame(['ASB', 'DKS', 'SSL'], array_values($choices)); + } + + /** + * @return FormInterface + */ + private function createForm(User $user): FormInterface + { + return Forms::createFormFactoryBuilder() + ->addType(new UserType(['SSL' => 'SSL', 'DKS' => 'DKS'])) + ->getFormFactory() + ->create(UserType::class, $user) + ; + } +} diff --git a/tests/Form/Model/Filter/UserFilterDtoTest.php b/tests/Form/Model/Filter/UserFilterDtoTest.php new file mode 100644 index 0000000..3992717 --- /dev/null +++ b/tests/Form/Model/Filter/UserFilterDtoTest.php @@ -0,0 +1,37 @@ +role = Role::GROUPS_ADMIN; + + $chip = $filter->activeFilters()[0]; + + self::assertSame('Rolle', $chip->label); + self::assertSame('Preisrechner Admin', $chip->value); + self::assertSame(['role'], $chip->removeKeys); + } + + public function testAnUnknownRoleIsShownVerbatim(): void + { + $filter = new UserFilterDto(); + $filter->role = 'ROLE_LEGACY'; + + self::assertSame('ROLE_LEGACY', $filter->activeFilters()[0]->value); + } + + public function testNoRoleMeansNoChip(): void + { + self::assertSame([], (new UserFilterDto())->activeFilters()); + } +} diff --git a/tests/Security/BpnAuthenticatorTest.php b/tests/Security/BpnAuthenticatorTest.php new file mode 100644 index 0000000..bed40bf --- /dev/null +++ b/tests/Security/BpnAuthenticatorTest.php @@ -0,0 +1,132 @@ +authenticator( + $this->crmAttributes([Role::ADMIN, Role::TEAMER, Role::GROUPS_ADMIN], ['SSL', 'SSL']), + null, + $persisted, + ); + + $user = $this->loadUser($authenticator); + + self::assertSame($persisted, $user); + self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles()); + self::assertSame(['SSL'], $user->getHotelCodes()); + } + + public function testExistingAccountKeepsTheRolesAnAdministratorAssigned(): void + { + $existing = (new User('teamer@example.org')) + ->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]) + ->setHotelCodes(['DKS']) + ; + + $persisted = null; + $authenticator = $this->authenticator( + $this->crmAttributes([Role::ADMIN, Role::CUSTOMER], ['SSL']), + $existing, + $persisted, + ); + + $user = $this->loadUser($authenticator); + + self::assertNull($persisted, 'an existing account must not be persisted again'); + self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles()); + self::assertSame(['DKS'], $user->getHotelCodes()); + self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced'); + } + + /** + * @param string[] $roles + * @param string[] $hotelCodes + */ + private function crmAttributes(array $roles, array $hotelCodes): CrmAttributes + { + $attributes = new CrmAttributes(); + $attributes->roles = $roles; + $attributes->hotelCodes = $hotelCodes; + + return $attributes; + } + + private function authenticator(CrmAttributes $crmAttributes, ?User $existing, ?User &$persisted): BpnAuthenticator + { + $personalData = new PersonalData(); + $personalData->personId = 42; + $personalData->addressId = 4711; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->method('getPersonalData')->willReturn($personalData); + $apiClient->method('getCrmAttributes')->willReturn($crmAttributes); + + $repository = $this->createMock(EntityRepository::class); + $repository->method('findOneBy')->willReturn($existing); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('getRepository')->willReturn($repository); + $entityManager + ->method('persist') + ->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }) + ; + + $crypt = $this->createMock(Crypt::class); + $crypt->method('encrypt')->willReturn('encrypted'); + + $completenessChecker = $this->createMock(ProfileCompletenessChecker::class); + $completenessChecker->method('isComplete')->willReturn(true); + + return new BpnAuthenticator( + $this->createMock(UrlGeneratorInterface::class), + $apiClient, + $entityManager, + $crypt, + $completenessChecker, + $this->createMock(LoggerInterface::class), + ); + } + + private function loadUser(BpnAuthenticator $authenticator): User + { + $request = new Request(); + $request->request->set('_username', 'teamer@example.org'); + $request->request->set('_password', 'secret'); + + $badge = $authenticator->authenticate($request)->getBadge(UserBadge::class); + self::assertInstanceOf(UserBadge::class, $badge); + + $user = $badge->getUser(); + self::assertInstanceOf(User::class, $user); + + return $user; + } +} diff --git a/tests/Security/RoleTest.php b/tests/Security/RoleTest.php new file mode 100644 index 0000000..83ef168 --- /dev/null +++ b/tests/Security/RoleTest.php @@ -0,0 +1,45 @@ + Role::ADMIN, 2 => Role::TEAMER, 5 => Role::TEAMER]); + + self::assertSame([Role::TEAMER], $roles); + self::assertSame(array_keys($roles), range(0, \count($roles) - 1)); + } + + public function testAccountWithOnlyPrivilegedRolesFallsBackToCustomer(): void + { + self::assertSame([Role::CUSTOMER], Role::filterImportable([Role::ADMIN])); + self::assertSame([Role::CUSTOMER], Role::filterImportable([])); + } + + public function testEveryRoleHasALabel(): void + { + self::assertSame(Role::ALL, array_keys(Role::labels())); + } +} diff --git a/tests/Twig/AppRuntimeMapRolesTest.php b/tests/Twig/AppRuntimeMapRolesTest.php new file mode 100644 index 0000000..9fcdec2 --- /dev/null +++ b/tests/Twig/AppRuntimeMapRolesTest.php @@ -0,0 +1,36 @@ +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(); + } +}