feat: admin-managed roles and hotel codes

This commit is contained in:
Björn Fromme
2026-08-10 10:07:24 +02:00
parent 124c0af0f5
commit 6c1073e41c
19 changed files with 718 additions and 34 deletions
-3
View File
@@ -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
-
+29
View File
@@ -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%'
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Form\Admin\UserType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/user/{id}/edit', name: 'app_admin_user_edit')]
public function index(User $user, Request $request): Response
{
$previousRoles = $user->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);
}
}
@@ -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', [
@@ -36,7 +36,6 @@ class IndexController extends AbstractController
UserFilterType::class,
$filter,
'app_admin_user',
['roles' => UserFilterType::ROLES],
);
$pagination = $this->paginator->paginate(
+2 -19
View File
@@ -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
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Security\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Lets an administrator assign roles and hotel codes to an existing account.
*
* This is the only way those two get changed after the account was created — BpnAuthenticator
* imports them once and never touches them again.
*
* @extends AbstractType<User>
*/
class UserType extends AbstractType
{
/**
* @param array<string, string> $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<string, string> 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,
]);
}
}
+2 -1
View File
@@ -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;
+34 -8
View File
@@ -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', [
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* The roles this application knows about.
*
* Roles are stored as plain strings in the User::$roles JSON column and consumed as strings
* by #[IsGranted], the voters and security.yaml's role_hierarchy, so they are constants
* rather than an enum.
*/
final class Role
{
public const ADMIN = 'ROLE_ADMIN';
public const MANAGER = 'ROLE_MANAGER';
public const TEAMER = 'ROLE_TEAMER';
public const CUSTOMER = 'ROLE_CUSTOMER';
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
/**
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
* account has it implicitly (see User::getRoles()) and it is never stored.
*
* @var string[]
*/
public const ALL = [
self::ADMIN,
self::MANAGER,
self::TEAMER,
self::CUSTOMER,
self::HOUSE_MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
];
/**
* Roles that are never taken over from BusProNet. Many people can edit CRM selections in
* the BusPro backend, so these are granted by an administrator in /admin/user only.
*
* @var string[]
*/
public const PRIVILEGED = [
self::ADMIN,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
];
/**
* Reduces the roles derived from BPN CRM attributes to the ones we accept from there.
*
* Falls back to ROLE_CUSTOMER the way CrmAttributesResponseParser does, so an account
* whose only selection was a privileged one does not end up without any role.
*
* @param string[] $roles
*
* @return string[]
*/
public static function filterImportable(array $roles): array
{
$importable = array_values(array_unique(array_diff($roles, self::PRIVILEGED)));
return [] === $importable ? [self::CUSTOMER] : $importable;
}
/**
* @return array<string, string> 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',
];
}
}
+1
View File
@@ -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']),
];
}
+28
View File
@@ -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);
+21 -1
View File
@@ -22,9 +22,16 @@
<th>
{{ knp_pagination_sortable(pagination, 'BusPro Personen Id', 'user.personId') }}
</th>
<th>
Rollen
</th>
<th>
Häuser
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Letzter Login', 'user.lastLoginAt') }}
</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -39,13 +46,26 @@
<td>
{{ user.personId | default('-') }}
</td>
<td>
{{ user.roles | map_roles | join(', ') | default('-') }}
</td>
<td>
{{ user.hotelCodes | join(', ') | default('-') }}
</td>
<td>
{{ user.lastLoginAt | date('d.m.Y, H:i') }}
</td>
<td class="text-right">
<twig:dropdown>
<twig:dropdown:hxbutton url="{{ path('app_admin_user_edit', { 'id': user.id, 'r': return_url() }) }}">
Berechtigungen bearbeiten
</twig:dropdown:hxbutton>
</twig:dropdown>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">
<td colspan="7">
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
</td>
</tr>
+24
View File
@@ -0,0 +1,24 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{% block title %}
Berechtigungen
{% endblock %}
{% block content %}
<div class="pb-4 text-sm text-gray-500">
{{ user.email }}
</div>
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
{{ form_row(form.roles) }}
{{ form_row(form.hotelCodes) }}
</div>
<div class="flex justify-end pt-8">
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
<?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 testStoredRolesArePreselectedWithoutTheImplicitRoleUser(): void
{
$user = (new User('[email protected]'))->setRoles([Role::TEAMER]);
self::assertSame([Role::TEAMER], $this->createForm($user)->get('roles')->getData());
}
public function testSubmittingRolesDoesNotStoreTheImplicitRoleUser(): void
{
$user = (new User('[email protected]'))->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('[email protected]'))
->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('[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)
;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Model\Filter;
use App\Form\Model\Filter\UserFilterDto;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
class UserFilterDtoTest extends TestCase
{
public function testTheRoleChipShowsTheLabelRatherThanTheRoleName(): void
{
$filter = new UserFilterDto();
$filter->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());
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Security\BpnAuthenticator;
use App\Security\Crypt;
use App\Security\Role;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
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.
*/
class BpnAuthenticatorTest extends TestCase
{
public function testNewAccountIsSeededWithTheImportableRolesOnly(): void
{
$persisted = null;
$authenticator = $this->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('[email protected]'))
->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', '[email protected]');
$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;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
class RoleTest extends TestCase
{
public function testPrivilegedRolesAreNeverImported(): void
{
$roles = Role::filterImportable([
Role::TEAMER,
Role::ADMIN,
Role::GROUPS_ADMIN,
Role::GROUPS_MANAGER,
Role::HOUSE_MANAGER,
]);
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
}
public function testResultIsADedupedList(): 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]);
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()));
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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();
}
}