From eff0f6bcda3e136d79f4ce0652d6de883fc03399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 23 Mar 2026 11:44:30 +0000 Subject: [PATCH] Ajout de permissions sur le module Ticket --- src/Bundle/ChillMainBundle/Entity/User.php | 30 ++++++ .../ChillMainBundle/Entity/UserGroup.php | 4 +- .../Authorization/VoterGeneratorInterface.php | 4 +- .../Tests/Entity/UserGroupTest.php | 50 ++++++++++ .../Repository/PersonACLAwareRepository.php | 2 - .../src/Controller/AddCommentController.php | 3 +- .../CenterForTicketListApiController.php | 4 +- .../ChangeEmergencyStateApiController.php | 9 +- .../Controller/ChangeStateApiController.php | 9 +- .../src/Controller/CreateTicketController.php | 5 +- .../src/Controller/EditTicketController.php | 12 ++- .../Controller/ReplaceMotiveController.php | 5 +- .../Controller/SetAddresseesController.php | 7 +- .../src/Controller/SetCallerApiController.php | 5 +- .../src/Controller/SetPersonsController.php | 5 +- .../SuggestPersonForTicketApiController.php | 5 +- .../src/Controller/TicketControllerApi.php | 14 ++- .../Controller/TicketListApiController.php | 5 +- .../src/Controller/TicketListController.php | 8 +- .../ChillTicketExtension.php | 7 ++ .../ChillTicketBundle/src/Entity/Ticket.php | 15 +++ .../src/Menu/PersonMenuBuilder.php | 4 +- .../Repository/TicketACLAwareRepository.php | 56 +++++++++++- .../Resolver/TicketCenterResolver.php | 51 +++++++++++ .../src/Security/Voter/TicketVoter.php | 51 ++++++++++- .../src/config/services.yaml | 3 + .../src/translations/messages+intl-icu.fr.yml | 10 +- .../Controller/AddCommentControllerTest.php | 3 +- .../ChangeEmergencyStateApiControllerTest.php | 11 +-- .../ChangeStateApiControllerTest.php | 11 +-- .../ReplaceMotiveControllerTest.php | 3 +- .../SetAddresseesControllerTest.php | 3 +- .../Controller/SetCallerApiControllerTest.php | 11 ++- .../tests/Entity/TicketTest.php | 38 ++++++++ .../Resolver/TicketCenterResolverTest.php | 91 +++++++++++++++++++ 35 files changed, 486 insertions(+), 68 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Tests/Entity/UserGroupTest.php create mode 100644 src/Bundle/ChillTicketBundle/src/Security/Resolver/TicketCenterResolver.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Security/Resolver/TicketCenterResolverTest.php diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index aca4134be..d13c1dd75 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -133,6 +133,9 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 5, nullable: false, options: ['default' => 'fr'])] private string $locale = 'fr'; + #[ORM\ManyToMany(targetEntity: UserGroup::class, mappedBy: 'users')] + private Collection&Selectable $groupsAsMember; + /** * User constructor. */ @@ -141,6 +144,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter $this->groupCenters = new ArrayCollection(); $this->scopeHistories = new ArrayCollection(); $this->jobHistories = new ArrayCollection(); + $this->groupsAsMember = new ArrayCollection(); } public function __toString(): string @@ -170,6 +174,32 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter return $this->absenceEnd; } + public function addGroupAsMember(UserGroup $userGroup): self + { + if (!$this->groupsAsMember->contains($userGroup)) { + $this->groupsAsMember->add($userGroup); + } + + return $this; + } + + public function removeGroupAsMember(UserGroup $userGroup): self + { + if ($this->groupsAsMember->contains($userGroup)) { + $this->groupsAsMember->removeElement($userGroup); + } + + return $this; + } + + /** + * @return Selectable&Collection + */ + public function getGroupsAsMember(): Collection&Selectable + { + return $this->groupsAsMember; + } + /** * Get attributes. * diff --git a/src/Bundle/ChillMainBundle/Entity/UserGroup.php b/src/Bundle/ChillMainBundle/Entity/UserGroup.php index 39df04b31..44de4efba 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserGroup.php +++ b/src/Bundle/ChillMainBundle/Entity/UserGroup.php @@ -54,7 +54,7 @@ class UserGroup /** * @var Collection&Selectable */ - #[ORM\ManyToMany(targetEntity: User::class)] + #[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'groupsAsMember')] #[ORM\JoinTable(name: 'chill_main_user_group_user')] private Collection&Selectable $users; @@ -129,6 +129,7 @@ class UserGroup { if (!$this->users->contains($user)) { $this->users[] = $user; + $user->addGroupAsMember($this); } return $this; @@ -138,6 +139,7 @@ class UserGroup { if ($this->users->contains($user)) { $this->users->removeElement($user); + $user->removeGroupAsMember($this); } return $this; diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php b/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php index 0c72ade3e..86b8ac7fe 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/VoterGeneratorInterface.php @@ -14,8 +14,8 @@ namespace Chill\MainBundle\Security\Authorization; interface VoterGeneratorInterface { /** - * @param string $class The FQDN of a class - * @param array $attributes an array of attributes + * @param string|null $class The FQDN of a class + * @param array $attributes an array of attributes * * @return $this */ diff --git a/src/Bundle/ChillMainBundle/Tests/Entity/UserGroupTest.php b/src/Bundle/ChillMainBundle/Tests/Entity/UserGroupTest.php new file mode 100644 index 000000000..4c5b692b6 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Entity/UserGroupTest.php @@ -0,0 +1,50 @@ +addUser($user); + + self::assertTrue($userGroup->getUsers()->contains($user)); + self::assertTrue($user->getGroupsAsMember()->contains($userGroup)); + } + + public function testRemoveUser(): void + { + $userGroup = new UserGroup(); + $user = new User(); + + $userGroup->addUser($user); + self::assertTrue($userGroup->getUsers()->contains($user)); + self::assertTrue($user->getGroupsAsMember()->contains($userGroup)); + + $userGroup->removeUser($user); + + self::assertFalse($userGroup->getUsers()->contains($user)); + self::assertFalse($user->getGroupsAsMember()->contains($userGroup)); + } +} diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php index b8b575008..b37e4a23a 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php @@ -12,7 +12,6 @@ declare(strict_types=1); namespace Chill\PersonBundle\Repository; use Chill\MainBundle\Entity\Center; -use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; @@ -32,7 +31,6 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor public function __construct( private Security $security, private EntityManagerInterface $em, - private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper, private PersonIdentifierManagerInterface $personIdentifierManager, ) {} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php index 08ff93453..b4f26f133 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\AddCommentCommand; use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Security\Voter\CommentVoter; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -38,7 +39,7 @@ final readonly class AddCommentController #[Route('/api/1.0/ticket/{id}/comment/add', name: 'chill_ticket_comment_add', methods: ['POST'])] public function __invoke(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { throw new AccessDeniedHttpException('Only user can add ticket comments.'); } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php index 11b2ac961..217864287 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php @@ -13,7 +13,7 @@ namespace Chill\TicketBundle\Controller; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; -use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -41,7 +41,7 @@ final readonly class CenterForTicketListApiController throw new AccessDeniedHttpException(); } - $centers = $this->authorizationHelperForCurrentUser->getReachableCenters(PersonVoter::SEE); + $centers = $this->authorizationHelperForCurrentUser->getReachableCenters(TicketVoter::READ); return new JsonResponse( $this->serializer->serialize($centers, 'json', ['groups' => 'read']), diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php index 1ebc442d6..eaf311aa2 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -38,8 +39,8 @@ final readonly class ChangeEmergencyStateApiController #[Route('/api/1.0/ticket/ticket/{id}/emergency/yes', name: 'chill_ticket_ticket_emergency_yes_api', requirements: ['id' => '\d+'], methods: ['POST'])] public function setEmergencyYes(Ticket $ticket): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to set emergency status to YES.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to set emergency status to YES.'); } $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); @@ -56,8 +57,8 @@ final readonly class ChangeEmergencyStateApiController #[Route('/api/1.0/ticket/ticket/{id}/emergency/no', name: 'chill_ticket_ticket_emergency_no_api', requirements: ['id' => '\d+'], methods: ['POST'])] public function setEmergencyNo(Ticket $ticket): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to set emergency status to NO.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to set emergency status to NO.'); } $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php index 55e09162d..f82dcebe6 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\ChangeStateCommand; use Chill\TicketBundle\Action\Ticket\Handler\ChangeStateCommandHandler; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -38,8 +39,8 @@ final readonly class ChangeStateApiController #[Route('/api/1.0/ticket/ticket/{id}/close', name: 'chill_ticket_ticket_close_api', requirements: ['id' => '\d+'], methods: ['POST'])] public function close(Ticket $ticket): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to close tickets.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to close tickets.'); } $command = new ChangeStateCommand(StateEnum::CLOSED); @@ -56,8 +57,8 @@ final readonly class ChangeStateApiController #[Route('/api/1.0/ticket/ticket/{id}/open', name: 'chill_ticket_ticket_open_api', requirements: ['id' => '\d+'], methods: ['POST'])] public function open(Ticket $ticket): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to open tickets.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to open tickets.'); } $command = new ChangeStateCommand(StateEnum::OPEN); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php index 8e47d3afa..539b06a01 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php @@ -16,6 +16,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandle use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; use Chill\TicketBundle\Repository\TicketRepositoryInterface; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -39,8 +40,8 @@ final readonly class CreateTicketController #[Route('{_locale}/ticket/ticket/create')] public function __invoke(Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to create tickets.'); + if (!$this->security->isGranted(TicketVoter::CREATE)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to create tickets.'); } if ('' !== $extId = $request->query->get('extId', '')) { diff --git a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php index a686208ad..7e483a795 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php @@ -12,17 +12,21 @@ declare(strict_types=1); namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; use Twig\Environment; -class EditTicketController +final readonly class EditTicketController { private readonly string $personPerTicket; public function __construct( - private readonly Environment $templating, + private Environment $templating, + private Security $security, ParameterBagInterface $parameterBag, ) { $this->personPerTicket = $parameterBag->get('chill_ticket')['ticket']['person_per_ticket']; @@ -32,6 +36,10 @@ class EditTicketController public function __invoke( Ticket $ticket, ): Response { + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Access denied'); + } + return new Response( $this->templating->render( '@ChillTicket/Ticket/edit.html.twig', diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php index 3074ee6c1..b2ddc3026 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php @@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -38,8 +39,8 @@ final readonly class ReplaceMotiveController #[Route('/api/1.0/ticket/{id}/motive/set', name: 'chill_ticket_motive_set', methods: ['POST'])] public function __invoke(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException(''); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Access denied'); } $command = $this->serializer->deserialize($request->getContent(), ReplaceMotiveCommand::class, 'json', [ diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php index 9fa6dc366..7d4add827 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\AddAddresseeCommand; use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler; use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -39,7 +40,7 @@ final readonly class SetAddresseesController #[Route('/api/1.0/ticket/{id}/addressees/set', methods: ['POST'])] public function setAddressees(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { throw new AccessDeniedHttpException('Only users can set addressees.'); } @@ -51,8 +52,8 @@ final readonly class SetAddresseesController #[Route('/api/1.0/ticket/{id}/addressee/add', methods: ['POST'])] public function addAddressee(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users can add addressees.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users can add addressees.'); } $command = $this->serializer->deserialize($request->getContent(), AddAddresseeCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php index 8393ca4d4..90961f88b 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php @@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Action\Ticket\SetCallerCommand; use Chill\TicketBundle\Action\Ticket\Handler\SetCallerCommandHandler; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -39,8 +40,8 @@ final readonly class SetCallerApiController #[Route('/api/1.0/ticket/ticket/{id}/set-caller', name: 'chill_ticket_ticket_set_caller_api', requirements: ['id' => '\d+'], methods: ['POST'])] public function setCaller(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to set ticket callers.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to set ticket callers.'); } try { diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php index 3fbe254a8..18df0b058 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php @@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Action\Ticket\Handler\SetPersonsCommandHandler; use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -38,8 +39,8 @@ final readonly class SetPersonsController #[Route('/api/1.0/ticket/{id}/persons/set', methods: ['POST'])] public function setPersons(Ticket $ticket, Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users can set addressees.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users can set addressees.'); } $command = $this->serializer->deserialize($request->getContent(), SetPersonsCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php index a71065b73..24b5644fc 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Chill\TicketBundle\Service\Ticket\SuggestPersonForTicketInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -31,8 +32,8 @@ final readonly class SuggestPersonForTicketApiController #[Route('/api/1.0/ticket/ticket/{id}/suggest-person', name: 'chill_ticket_ticket_suggest_person_api', requirements: ['id' => '\d+'], methods: ['GET'])] public function __invoke(Ticket $ticket): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to suggest persons for tickets.'); + if (!$this->security->isGranted(TicketVoter::WRITE, $ticket)) { + throw new AccessDeniedHttpException('Only allowed users are allowed to suggest persons for tickets.'); } $persons = $this->suggestPersonForTicket->suggestPerson($ticket, 0, 10); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php index aa9b16a59..cd85921da 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php @@ -12,17 +12,27 @@ declare(strict_types=1); namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Serializer\SerializerInterface; -class TicketControllerApi +final readonly class TicketControllerApi { - public function __construct(private readonly SerializerInterface $serializer) {} + public function __construct( + private SerializerInterface $serializer, + private Security $security, + ) {} #[Route('/api/1.0/ticket/ticket/{id}', requirements: ['id' => '\d+'], methods: ['GET'])] public function get(Ticket $ticket): JsonResponse { + if (!$this->security->isGranted(TicketVoter::READ, $ticket)) { + throw new AccessDeniedHttpException('Access denied'); + } + return new JsonResponse( $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), json: true diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php index 1a4e5ccdb..0753ce220 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php @@ -22,6 +22,7 @@ use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Repository\MotiveRepository; use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -55,8 +56,8 @@ final readonly class TicketListApiController #[Route('/api/1.0/ticket/ticket/list', name: 'chill_ticket_list_api', methods: ['GET'])] public function listTicket(Request $request): JsonResponse { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('Only users are allowed to list tickets.'); + if (!$this->security->isGranted(TicketVoter::READ)) { + throw new AccessDeniedHttpException('only allowed user can access this page'); } $params = []; diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php index 7a25bbbc5..7b78fd7dd 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php @@ -11,8 +11,8 @@ declare(strict_types=1); namespace Chill\TicketBundle\Controller; -use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -30,8 +30,8 @@ final readonly class TicketListController #[Route('/{_locale}/ticket/ticket/list', name: 'chill_ticket_ticket_list')] public function __invoke(Request $request): Response { - if (!$this->security->isGranted('ROLE_USER')) { - throw new AccessDeniedHttpException('only user can access this page'); + if (!$this->security->isGranted(TicketVoter::READ)) { + throw new AccessDeniedHttpException('only allowed user can access this page'); } return new Response( @@ -42,7 +42,7 @@ final readonly class TicketListController #[Route('/{_locale}/ticket/by-person/{id}/list', name: 'chill_person_ticket_list')] public function listByPerson(Request $request, Person $person): Response { - if (!$this->security->isGranted(PersonVoter::SEE, $person)) { + if (!$this->security->isGranted(TicketVoter::READ, $person)) { throw new AccessDeniedHttpException('you are not allowed to see this person'); } diff --git a/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php b/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php index a990cfa16..a3c95205f 100644 --- a/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php +++ b/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Controller\Admin\MotiveController; use Chill\TicketBundle\Controller\MotiveApiController; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Form\MotiveType; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; @@ -39,6 +40,12 @@ class ChillTicketExtension extends Extension implements PrependExtensionInterfac { $this->prependApi($container); $this->prependCruds($container); + + $container->prependExtensionConfig('security', [ + 'role_hierarchy' => [ + TicketVoter::WRITE => [TicketVoter::CREATE, TicketVoter::READ], + ], + ]); } private function prependApi(ContainerBuilder $container): void diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index 092aa5a35..24b87bda9 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -333,4 +333,19 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface { return $this->callerHistories; } + + public function containsAddressee(User $user): bool + { + foreach ($this->getCurrentAddressee() as $addressee) { + if ($addressee instanceof User && $addressee === $user) { + return true; + } + + if ($addressee instanceof UserGroup && $addressee->contains($user)) { + return true; + } + } + + return false; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php b/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php index ce780f036..98cf1579f 100644 --- a/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php +++ b/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php @@ -12,9 +12,9 @@ declare(strict_types=1); namespace Chill\TicketBundle\Menu; use Chill\PersonBundle\Entity\Person; -use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\TicketBundle\Repository\TicketRepositoryInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Knp\Menu\MenuItem; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -47,7 +47,7 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface /** @var Person $person */ $person = $parameters['person']; - if ($this->authorizationChecker->isGranted(PersonVoter::SEE, $person)) { + if ($this->authorizationChecker->isGranted(TicketVoter::READ, $person)) { $menu->addChild($this->translator->trans('chill_ticket.list.title_menu'), [ 'route' => 'chill_person_ticket_list', 'routeParameters' => [ diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php index 442fc077e..b54e4ed11 100644 --- a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php @@ -11,19 +11,27 @@ declare(strict_types=1); namespace Chill\TicketBundle\Repository; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; +use Chill\MainBundle\Security\ChillSecurity; use Chill\PersonBundle\Entity\Person\PersonCenterHistory; use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\CallerHistory; use Chill\TicketBundle\Entity\EmergencyStatusHistory; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; final readonly class TicketACLAwareRepository implements TicketACLAwareRepositoryInterface { - public function __construct(private EntityManagerInterface $em) {} + public function __construct( + private EntityManagerInterface $em, + private ChillSecurity $security, + private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser, + ) {} public function findTickets(array $params, int $start = 0, int $limit = 100): array { @@ -36,18 +44,62 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor // most recent tickets first $query->addOrderBy('t.createdAt', 'DESC'); + $this->appendACLQuery($query); + return $query->getQuery() ->setFirstResult($start) ->setMaxResults($limit) ->getResult(); } + private function appendACLQuery(QueryBuilder $qb): void + { + $user = $this->security->getUser(); + $groups = $user->getGroupsAsMember(); + $centers = $this->authorizationHelperForCurrentUser->getReachableCenters(TicketVoter::READ); + + $orx = $qb->expr()->orX( + 't.createdBy = :user', + $qb->expr()->exists('SELECT 1 FROM '.AddresseeHistory::class.' ag_acl + WHERE ag_acl.ticket = t AND ag_acl.endDate IS NULL AND (ag_acl.addresseeUser = :user OR ag_acl.addresseeGroup IN (:groups))'), + $qb->expr()->exists('SELECT 1 FROM '.PersonHistory::class.' ph_acl JOIN '.PersonCenterHistory::class.' ph_acl_person_history WITH ph_acl_person_history.person = ph_acl.person + WHERE ph_acl.ticket = t AND ph_acl.endDate IS NULL and ph_acl_person_history.endDate IS NULL AND ph_acl_person_history.center IN (:centers)'), + $qb->expr()->exists('SELECT 1 FROM '.CallerHistory::class.' ch_acl JOIN '.PersonCenterHistory::class.' ch_acl_person_history WITH ch_acl_person_history.person = ch_acl.person + WHERE ch_acl.ticket = t AND ch_acl.endDate IS NULL and ch_acl_person_history.endDate IS NULL AND ch_acl_person_history.center IN (:centers)'), + ); + + $qb->andWhere($orx) + ->setParameter('user', $user) + ->setParameter('groups', $groups) + ->setParameter('centers', $centers); + } + public function countTickets(array $params): int { return $this->buildQuery($params)->select('COUNT(t)')->getQuery()->getSingleScalarResult(); } - private function buildQuery(array $params): QueryBuilder + /** + * Builds a Doctrine QueryBuilder object based on the given parameters to filter + * tickets by various conditions such as ticket ID, associated persons, current states, + * emergencies, motives, creation dates, creators, addressees, and related centers. + * + * @param array $params An associative array of query filters. Supported keys include: + * - 'byTicketId' (array): Filters tickets by IDs. + * - 'byPerson' (array): Filters tickets associated with specific persons. + * - 'byCurrentState' (array): Filters tickets in specific states. + * - 'byCurrentStateEmergency' (array): Filters tickets with specific emergency statuses. + * - 'byMotives' (array): Filters tickets by motives including their descendants. + * - 'byCreatedAfter' (\DateTime): Filters tickets created on or after the specified date. + * - 'byCreatedBefore' (\DateTime): Filters tickets created on or before the specified date. + * - 'byCreator' (array): Filters tickets created by specific users. + * - 'byAddressee' (array): Filters tickets addressed to specific users or groups. + * - 'byAddresseeGroup' (array): Filters tickets addressed to specific groups. + * - 'byPersonCenter' (array): Filters tickets associated with specific person centers. + * + * @return QueryBuilder the built Doctrine QueryBuilder object containing the applied filters + */ + public function buildQuery(array $params): QueryBuilder { $qb = $this->em->createQueryBuilder(); $qb->from(Ticket::class, 't'); diff --git a/src/Bundle/ChillTicketBundle/src/Security/Resolver/TicketCenterResolver.php b/src/Bundle/ChillTicketBundle/src/Security/Resolver/TicketCenterResolver.php new file mode 100644 index 000000000..87051784e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Resolver/TicketCenterResolver.php @@ -0,0 +1,51 @@ +getPersons() as $person) { + foreach ($this->manager->resolveCenters($person, $options) as $center) { + $centers[spl_object_hash($center)] = $center; + } + } + + return array_values($centers); + } + + public function supports($entity, ?array $options = []): bool + { + return $entity instanceof Ticket; + } + + public function setResolverManager(CenterResolverManagerInterface $manager): void + { + $this->manager = $manager; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php index 284dd55a8..bf1444385 100644 --- a/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php +++ b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php @@ -11,6 +11,11 @@ declare(strict_types=1); namespace Chill\TicketBundle\Security\Voter; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Security\Authorization\VoterHelperFactoryInterface; +use Chill\MainBundle\Security\Authorization\VoterHelperInterface; +use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; +use Chill\PersonBundle\Entity\Person; use Chill\TicketBundle\Entity\Ticket; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -18,20 +23,58 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter; /** * Check permission on Ticket. */ -final class TicketVoter extends Voter +final class TicketVoter extends Voter implements ProvideRoleHierarchyInterface { public const WRITE = 'CHILL_TICKET_TICKET_WRITE'; + + public const CREATE = 'CHILL_TICKET_TICKET_CREATE'; public const READ = 'CHILL_TICKET_TICKET_READ'; - private const ALL = [self::WRITE, self::READ]; + private const ALL = [self::WRITE, self::READ, self::CREATE]; + + private readonly VoterHelperInterface $voterHelper; + + public function __construct(VoterHelperFactoryInterface $voterHelperFactory) + { + $this->voterHelper = $voterHelperFactory->generate(self::class) + ->addCheckFor(Ticket::class, self::ALL) + ->addCheckFor(null, [self::CREATE, self::READ]) + ->addCheckFor(Person::class, [self::READ]) + ->build(); + } protected function supports(string $attribute, $subject): bool { - return $subject instanceof Ticket && in_array($attribute, self::ALL, true); + return $this->voterHelper->supports($attribute, $subject); } protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool { - return true; + assert($subject instanceof Ticket || null === $subject); + + $user = $token->getUser(); + + if ($subject instanceof Ticket && $user instanceof User && ($user === $subject->getCreatedBy() || $subject->containsAddressee($user))) { + return true; + } + + return $this->voterHelper->voteOnAttribute($attribute, $subject, $token); + } + + public function getRolesWithHierarchy(): array + { + return [ + 'ticket.permission.title' => [self::READ, self::WRITE], + ]; + } + + public function getRoles(): array + { + return [self::WRITE, self::READ]; + } + + public function getRolesWithoutScope(): array + { + return [self::WRITE, self::READ]; } } diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index b7f6df2c2..ce0867831 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -29,6 +29,9 @@ services: Chill\TicketBundle\Security\Authorization\: resource: '../Security/Authorization/' + Chill\TicketBundle\Security\Resolver\: + resource: '../Security/Resolver/' + Chill\TicketBundle\Serializer\: resource: '../Serializer/' diff --git a/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml index 62f73f985..f8fcbc24f 100644 --- a/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml +++ b/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml @@ -3,7 +3,7 @@ chill_ticket: list: title: "Tickets" title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}" - title_menu: "Tickets de l'usager" + title_menu: "Tickets" show_all_history: "Afficher tout l'historique" title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}" no_tickets: "Aucun ticket" @@ -187,3 +187,11 @@ crud: "No supplementary comments": "Aucun commentaire supplémentaire" "Back to the list": "Retour à la liste" "Edit": "Modifier" + +# permissions +ticket: + permission: + title: "Tickets" +CHILL_TICKET_TICKET_WRITE: Modifier et créer des tickets +CHILL_TICKET_TICKET_READ: Consulter les tickets +CHILL_TICKET_TICKET_CREATE: Créer des tickets diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php index f954fd084..f9824983c 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php @@ -16,6 +16,7 @@ use Chill\TicketBundle\Controller\AddCommentController; use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Security\Voter\CommentVoter; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -83,7 +84,7 @@ class AddCommentControllerTest extends KernelTestCase private function buildController(bool $willFlush): AddCommentController { $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, Argument::type(Ticket::class))->willReturn(true); $entityManager = $this->prophesize(EntityManagerInterface::class); diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php index 1a479bc64..bd96912c4 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php @@ -16,6 +16,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; use Chill\TicketBundle\Controller\ChangeEmergencyStateApiController; use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -38,7 +39,7 @@ final class ChangeEmergencyStateApiControllerTest extends TestCase { $ticket = new Ticket(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(false); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(false); $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -58,9 +59,8 @@ final class ChangeEmergencyStateApiControllerTest extends TestCase public function testSetEmergencyYesWithPermission(): void { $ticket = new Ticket(); - $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); $changeEmergencyStateCommandHandler->__invoke( @@ -92,7 +92,7 @@ final class ChangeEmergencyStateApiControllerTest extends TestCase { $ticket = new Ticket(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(false); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(false); $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -112,9 +112,8 @@ final class ChangeEmergencyStateApiControllerTest extends TestCase public function testSetEmergencyNoWithPermission(): void { $ticket = new Ticket(); - $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); $changeEmergencyStateCommandHandler->__invoke( diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php index ab8c2b3cb..c366eefc0 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php @@ -16,6 +16,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\ChangeStateCommandHandler; use Chill\TicketBundle\Controller\ChangeStateApiController; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -38,7 +39,7 @@ final class ChangeStateApiControllerTest extends TestCase { $ticket = new Ticket(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(false); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(false); $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -58,9 +59,8 @@ final class ChangeStateApiControllerTest extends TestCase public function testCloseWithPermission(): void { $ticket = new Ticket(); - $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); $changeStateCommandHandler->__invoke( @@ -92,7 +92,7 @@ final class ChangeStateApiControllerTest extends TestCase { $ticket = new Ticket(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(false); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(false); $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -112,9 +112,8 @@ final class ChangeStateApiControllerTest extends TestCase public function testOpenWithPermission(): void { $ticket = new Ticket(); - $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); $changeStateCommandHandler->__invoke( diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php index 28bf7dac5..4a0ec8ffc 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php @@ -17,6 +17,7 @@ use Chill\TicketBundle\Entity\Motive; use PHPUnit\Framework\TestCase; use Chill\TicketBundle\Controller\ReplaceMotiveController; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; @@ -41,7 +42,7 @@ class ReplaceMotiveControllerTest extends TestCase // Mock Security $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); // Mock EntityManager $entityManager = $this->prophesize(EntityManagerInterface::class); diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php index 5ff4bfd77..00dfc0002 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -18,6 +18,7 @@ use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; use Chill\TicketBundle\Controller\SetAddresseesController; use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -179,7 +180,7 @@ class SetAddresseesControllerTest extends KernelTestCase { $user = new User(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, Argument::type(Ticket::class))->willReturn(true); $security->getUser()->willReturn($user); $entityManager = $this->prophesize(EntityManagerInterface::class); diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php index aad6342dd..4cc4ba0da 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php @@ -17,6 +17,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\SetCallerCommandHandler; use Chill\TicketBundle\Action\Ticket\SetCallerCommand; use Chill\TicketBundle\Controller\SetCallerApiController; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\TicketVoter; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -42,7 +43,7 @@ final class SetCallerApiControllerTest extends TestCase $request = new Request(); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(false); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(false); $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -65,7 +66,7 @@ final class SetCallerApiControllerTest extends TestCase $request = new Request([], [], [], [], [], [], 'invalid json'); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); $entityManager = $this->prophesize(EntityManagerInterface::class); @@ -94,7 +95,7 @@ final class SetCallerApiControllerTest extends TestCase $command = new SetCallerCommand($person); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $serializer = $this->prophesize(SerializerInterface::class); $serializer->deserialize('{"caller": {"id": 123, "type": "person"}}', SetCallerCommand::class, 'json') @@ -132,7 +133,7 @@ final class SetCallerApiControllerTest extends TestCase $command = new SetCallerCommand($thirdParty); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $serializer = $this->prophesize(SerializerInterface::class); $serializer->deserialize('{"caller": {"id": 456, "type": "thirdParty"}}', SetCallerCommand::class, 'json') @@ -169,7 +170,7 @@ final class SetCallerApiControllerTest extends TestCase $command = new SetCallerCommand(null); $security = $this->prophesize(Security::class); - $security->isGranted('ROLE_USER')->willReturn(true); + $security->isGranted(TicketVoter::WRITE, $ticket)->willReturn(true); $serializer = $this->prophesize(SerializerInterface::class); $serializer->deserialize('{"caller": null}', SetCallerCommand::class, 'json') diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index 975a8e2e2..bc6ce7733 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -158,4 +158,42 @@ class TicketTest extends KernelTestCase self::assertCount(2, $ticket->getEmergencyStatusHistories()); self::assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus()); } + + public function testContainsAddressee(): void + { + $ticket = new Ticket(); + $user1 = new User(); + $user2 = new User(); + $user3 = new User(); + $group = new UserGroup(); + + // Initial state + self::assertFalse($ticket->containsAddressee($user1)); + + // user1 is direct addressee + $history1 = new AddresseeHistory($user1, new \DateTimeImmutable(), $ticket); + + // group is addressee, and user2 is in group + $history2 = new AddresseeHistory($group, new \DateTimeImmutable(), $ticket); + + // user3 is not addressee + self::assertTrue($ticket->containsAddressee($user1)); + self::assertFalse($ticket->containsAddressee($user2)); // Not in group yet + self::assertFalse($ticket->containsAddressee($user3)); + + // Add user2 to group + $group->addUser($user2); + self::assertTrue($ticket->containsAddressee($user2)); + + // End user1 history + $history1->setEndDate(new \DateTimeImmutable()); + self::assertFalse($ticket->containsAddressee($user1)); + + // user2 is still in via group + self::assertTrue($ticket->containsAddressee($user2)); + + // End group history + $history2->setEndDate(new \DateTimeImmutable()); + self::assertFalse($ticket->containsAddressee($user2)); + } } diff --git a/src/Bundle/ChillTicketBundle/tests/Security/Resolver/TicketCenterResolverTest.php b/src/Bundle/ChillTicketBundle/tests/Security/Resolver/TicketCenterResolverTest.php new file mode 100644 index 000000000..85216bd21 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Security/Resolver/TicketCenterResolverTest.php @@ -0,0 +1,91 @@ +resolver = new TicketCenterResolver(); + } + + public function testSupports(): void + { + $ticket = $this->prophesize(Ticket::class)->reveal(); + $otherEntity = new \stdClass(); + + $this->assertTrue($this->resolver->supports($ticket)); + $this->assertFalse($this->resolver->supports($otherEntity)); + } + + public function testGetDefaultPriority(): void + { + $this->assertSame(0, TicketCenterResolver::getDefaultPriority()); + } + + public function testResolveCenterWithNoPersons(): void + { + $ticket = $this->prophesize(Ticket::class); + $ticket->getPersons()->willReturn([]); + + $manager = $this->prophesize(CenterResolverManagerInterface::class); + $this->resolver->setResolverManager($manager->reveal()); + + $result = $this->resolver->resolveCenter($ticket->reveal()); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testResolveCenterWithPersons(): void + { + $person1 = $this->prophesize(Person::class)->reveal(); + $person2 = $this->prophesize(Person::class)->reveal(); + + $ticket = $this->prophesize(Ticket::class); + $ticket->getPersons()->willReturn([$person1, $person2]); + + $center1 = new Center(); + $center2 = new Center(); + + $options = ['some' => 'option']; + + $manager = $this->prophesize(CenterResolverManagerInterface::class); + $manager->resolveCenters($person1, $options)->willReturn([$center1]); + $manager->resolveCenters($person2, $options)->willReturn([$center1, $center2]); + + $this->resolver->setResolverManager($manager->reveal()); + + $result = $this->resolver->resolveCenter($ticket->reveal(), $options); + + $this->assertCount(2, $result); + $this->assertContains($center1, $result); + $this->assertContains($center2, $result); + } +}