Merge branch 'task/1421-backend-droits-pour-les-tickets-visualiser-modifier-supprimer' into 'ticket-app-master'

Ajout de permissions sur le module Ticket

See merge request Chill-Projet/chill-bundles!975
This commit is contained in:
2026-03-23 11:44:30 +00:00
35 changed files with 486 additions and 68 deletions

View File

@@ -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<int, UserGroup>
*/
public function getGroupsAsMember(): Collection&Selectable
{
return $this->groupsAsMember;
}
/**
* Get attributes.
*

View File

@@ -54,7 +54,7 @@ class UserGroup
/**
* @var Collection<int, User>&Selectable<int, User>
*/
#[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;

View File

@@ -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
*/

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Entity;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class UserGroupTest extends TestCase
{
public function testAddUser(): void
{
$userGroup = new UserGroup();
$user = new User();
$userGroup->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));
}
}

View File

@@ -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,
) {}

View File

@@ -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.');
}

View File

@@ -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']),

View File

@@ -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);

View File

@@ -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);

View File

@@ -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', '')) {

View File

@@ -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',

View File

@@ -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', [

View File

@@ -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']]);

View File

@@ -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 {

View File

@@ -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']]);

View File

@@ -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);

View File

@@ -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

View File

@@ -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 = [];

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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' => [

View File

@@ -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');

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Security\Resolver;
use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Security\Resolver\ManagerAwareCenterResolverInterface;
use Chill\TicketBundle\Entity\Ticket;
class TicketCenterResolver implements CenterResolverInterface, ManagerAwareCenterResolverInterface
{
private ?CenterResolverManagerInterface $manager = null;
public static function getDefaultPriority(): int
{
return 0;
}
public function resolveCenter($entity, ?array $options = [])
{
assert($entity instanceof Ticket);
$centers = [];
foreach ($entity->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;
}
}

View File

@@ -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];
}
}

View File

@@ -29,6 +29,9 @@ services:
Chill\TicketBundle\Security\Authorization\:
resource: '../Security/Authorization/'
Chill\TicketBundle\Security\Resolver\:
resource: '../Security/Resolver/'
Chill\TicketBundle\Serializer\:
resource: '../Serializer/'

View File

@@ -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

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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')

View File

@@ -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));
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Security\Resolver;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Security\Resolver\TicketCenterResolver;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class TicketCenterResolverTest extends TestCase
{
use ProphecyTrait;
private TicketCenterResolver $resolver;
protected function setUp(): void
{
$this->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);
}
}