From a990591e0cadcb5d2f70b61ab9c60df5c8a88036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 5 Jul 2023 16:23:14 +0200 Subject: [PATCH] handle right to see confidential course on regulation list --- ...mpanyingPeriodRegulationListController.php | 1 + .../AccompanyingPeriodACLAwareRepository.php | 122 ++++++++++----- ...nyingPeriodACLAwareRepositoryInterface.php | 2 +- ...companyingPeriodACLAwareRepositoryTest.php | 141 +++++++++++++++++- 4 files changed, 229 insertions(+), 37 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php index 6bbb6c368..cd550ef59 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingPeriodRegulationListController.php @@ -78,6 +78,7 @@ class AccompanyingPeriodRegulationListController $form['jobs']->getData(), $form['services']->getData(), $form['locations']->getData(), + ['openingDate' => 'DESC', 'id' => 'DESC'], $paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber() ); diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php index 104f49ec7..e62e2b3ac 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Repository; use Chill\MainBundle\Entity\Address; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; @@ -26,6 +27,8 @@ use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use DateTime; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\NoResultException; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Repository\AccompanyingPeriodACLAwareRepositoryTest; @@ -107,9 +110,16 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin return $qb; } + /** + * @throws NonUniqueResultException + * @throws NoResultException + */ public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int { - $qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); + $qb = $this->addACLByUnDispatched( + $this->buildQueryUnDispatched($jobs, $services, $administrativeLocations), + $this->buildCenterOnScope() + ); $qb->select('COUNT(ap)'); @@ -194,6 +204,8 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin } /** + * Add clause for scope on a query, based on no + * * @param QueryBuilder $qb where the accompanying period have the `ap` alias * @param array $scopesCanSee * @param array $scopesCanSeeConfidential @@ -252,19 +264,27 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin return $qb; } - public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array + public function buildCenterOnScope(): array { - $qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); + $centerOnScopes = []; + foreach ($this->authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE) as $center) { + $centerOnScopes[] = [ + 'center' => $center, + 'scopeOnRole' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center), + 'scopeCanSeeConfidential' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center), + ]; + } + return $centerOnScopes; + } + + public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + $qb = $this->buildQueryUnDispatched($jobs, $services, $administrativeAdministrativeLocations); $qb->select('ap'); - if (null !== $limit) { - $qb->setMaxResults($limit); - } - - if (null !== $offset) { - $qb->setFirstResult($offset); - } + $qb = $this->addACLByUnDispatched($qb, $this->buildCenterOnScope(), false); + $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset); return $qb->getQuery()->getResult(); } @@ -305,41 +325,73 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin return $qb->getQuery()->getResult(); } - private function addACLByUnDispatched(QueryBuilder $qb): QueryBuilder + /** + * @param QueryBuilder $qb + * @param list, scopeCanSeeConfidential: list}> $centerScopes + * @param bool $allowNoCenter if true, will allow to see the periods linked to person which does not have any center. Very few edge case when some Person are not associated to a center. + * @return QueryBuilder + */ + public function addACLByUnDispatched(QueryBuilder $qb, array $centerScopes, bool $allowNoCenter = false): QueryBuilder { - $centers = $this->authorizationHelper->getReachableCenters( - AccompanyingPeriodVoter::SEE - ); + $user = $this->security->getUser(); - $orX = $qb->expr()->orX(); - - if (0 === count($centers)) { + if (0 === count($centerScopes) || !$user instanceof User) { return $qb->andWhere("'FALSE' = 'TRUE'"); } - foreach ($centers as $key => $center) { - $scopes = $this->authorizationHelper - ->getReachableScopes( - AccompanyingPeriodVoter::SEE, - $center - ); + $orX = $qb->expr()->orX(); + $idx = 0; + foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) { $and = $qb->expr()->andX( - $qb->expr()->exists('SELECT part FROM ' . AccompanyingPeriodParticipation::class . ' part ' . - "JOIN part.person p WHERE part.accompanyingPeriod = ap.id AND p.center = :center_{$key}") + $qb->expr()->exists( + 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . " part_{$idx} " . + "JOIN part_{$idx}.person p{$idx} LEFT JOIN p{$idx}.centerCurrent centerCurrent_{$idx} " . + "WHERE part_{$idx}.accompanyingPeriod = ap.id AND (centerCurrent_{$idx}.center = :center_{$idx}" + . ($allowNoCenter ? " OR centerCurrent_{$idx}.id IS NULL)" : ")") + ) ); - $qb->setParameter('center_' . $key, $center); - $orScope = $qb->expr()->orX(); + $qb->setParameter('center_' . $idx, $center); - foreach ($scopes as $skey => $scope) { - $orScope->add( - $qb->expr()->isMemberOf(':scope_' . $key . '_' . $skey, 'ap.scopes') + $orScopeInsideCenter = $qb->expr()->orX( + // even if the scope is not in one authorized, the user can see the course if it is in DRAFT state + $qb->expr()->eq('ap.step', ':draft') + ); + + $idx++; + foreach ($scopes as $scope) { + // for each scope: + // - either the user is the referrer of the course + // - or the accompanying course is one of the reachable scopes + // - and the parcours is not confidential OR the user is the referrer OR the user can see the confidential course + $orOnScope = $qb->expr()->orX( + $qb->expr()->isMemberOf(':scope_' . $idx, 'ap.scopes'), + $qb->expr()->eq('ap.user', ':user') ); - $qb->setParameter('scope_' . $key . '_' . $skey, $scope); + $qb->setParameter('user', $user); + + if (in_array($scope, $scopesCanSeeConfidential, true)) { + $orScopeInsideCenter->add($orOnScope); + } else { + // we must add a condition: the course is not confidential or the user is the referrer + $andXOnScope = $qb->expr()->andX( + $orOnScope, + $qb->expr()->orX( + 'ap.confidential = FALSE', + $qb->expr()->eq('ap.user', ':user') + ) + ); + $orScopeInsideCenter->add($andXOnScope); + } + $qb->setParameter('scope_' . $idx, $scope); + + $idx++; } - $and->add($orScope); + $and->add($orScopeInsideCenter); $orX->add($and); + + $idx++; } return $qb->andWhere($orX); @@ -350,7 +402,7 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin * @param array|Scope[] $services * @param array|Location[] $locations */ - private function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder + public function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder { $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); @@ -378,8 +430,8 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin $or = $qb->expr()->orX(); foreach ($services as $key => $service) { - $or->add($qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes')); - $qb->setParameter('scope_' . $key, $service); + $or->add($qb->expr()->isMemberOf(':scopef_' . $key, 'ap.scopes')); + $qb->setParameter('scopef_' . $key, $service); } $qb->andWhere($or); } diff --git a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php index ff3d89783..61b631904 100644 --- a/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriodACLAwareRepositoryInterface.php @@ -50,7 +50,7 @@ interface AccompanyingPeriodACLAwareRepositoryInterface * * @return list */ - public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array; + public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; /** * @param array|PostalCode[] $postalCodes diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php index 17c12fea5..90e0d2384 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/AccompanyingPeriodACLAwareRepositoryTest.php @@ -11,8 +11,10 @@ declare(strict_types=1); namespace Repository; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Repository\CenterRepositoryInterface; use Chill\MainBundle\Repository\ScopeRepositoryInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; @@ -35,10 +37,13 @@ use Symfony\Component\Workflow\Registry; class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase { use ProphecyTrait; + private AccompanyingPeriodRepository $accompanyingPeriodRepository; private CenterResolverManagerInterface $centerResolverManager; + private CenterRepositoryInterface $centerRepository; + private EntityManagerInterface $entityManager; private ScopeRepositoryInterface $scopeRepository; @@ -51,11 +56,11 @@ class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase { self::bootKernel(); $this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class); + $this->centerRepository = self::$container->get(CenterRepositoryInterface::class); $this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class); $this->entityManager = self::$container->get(EntityManagerInterface::class); $this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class); $this->registry = self::$container->get(Registry::class); - } public static function tearDownAfterClass(): void @@ -78,6 +83,140 @@ class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase $em->flush(); } + /** + * @dataProvider provideDataFindByUndispatched + * @param list, scopeCanSeeConfidential: list}> $centerScopes + * @param list $expectedContains + * @param list $expectedNotContains + */ + public function testFindByUndispatched(User $user, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void + { + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn($user); + + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $centers = []; + + foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) { + $centers[spl_object_hash($center)] = $center; + $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center) + ->willReturn($scopes); + $authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center) + ->willReturn($scopesCanSeeConfidential); + } + $authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers)); + + $repository = new AccompanyingPeriodACLAwareRepository( + $this->accompanyingPeriodRepository, + $security->reveal(), + $authorizationHelper->reveal(), + $this->centerResolverManager + ); + + $actual = array_map( + fn (AccompanyingPeriod $period) => $period->getId(), + $repository->findByUnDispatched([], [], [], ['id' => 'DESC'], 20, 0) + ); + + foreach ($expectedContains as $expected) { + self::assertContains($expected->getId(), $actual, $message); + } + foreach ($expectedNotContains as $expected) { + self::assertNotContains($expected->getId(), $actual, $message); + } + } + + public function provideDataFindByUndispatched(): iterable + { + $this->setUp(); + + if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("no user found"); + } + + if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId()) + ->setMaxResults(1)->getSingleResult()) { + throw new \RuntimeException("no user found"); + } + + /** @var Person $person */ + [$person, $anotherPerson, $person2, $person3] = $this->entityManager + ->createQuery("SELECT p FROM " . Person::class . " p ") + ->setMaxResults(4) + ->getResult(); + + if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) { + throw new \RuntimeException("no person found"); + } + + $scopes = $this->scopeRepository->findAll(); + + if (3 > count($scopes)) { + throw new \RuntimeException("not enough scopes for this test"); + } + $scopesCanSee = [ $scopes[0] ]; + $scopesGroup2 = [ $scopes[1] ]; + + $centers = $this->centerRepository->findActive(); + + if (2 > count($centers)) { + throw new \RuntimeException("not enough centers for this test"); + } + + $period = $this->buildPeriod($person, $scopesCanSee, $user, true); + + // expected scope: can see the period + yield [ + $anotherUser, + [ + [ + 'center' => $person->getCenter(), + 'scopeOnRole' => $scopesCanSee, + 'scopeCanSeeConfidential' => [], + ], + ], + [$period], + [], + "period should be visible with expected scopes", + ]; + + // no scope visible + yield [ + $anotherUser, + [ + [ + 'center' => $person->getCenter(), + 'scopeOnRole' => $scopesGroup2, + 'scopeCanSeeConfidential' => [], + ], + ], + [], + [$period], + "period should not be visible without expected scopes", + ]; + + // another center + yield [ + $anotherUser, + [ + [ + 'center' => $person->getCenter(), + 'scopeOnRole' => $scopesGroup2, + 'scopeCanSeeConfidential' => [], + ], + [ + 'center' => array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0], + 'scopeOnRole' => $scopesCanSee, + 'scopeCanSeeConfidential' => [], + ], + ], + [], + [$period], + "period should not be visible for user having right in another scope (with multiple centers)" + ]; + + $this->entityManager->flush(); + } /** * For testing this method, we mock the authorization helper to return different Scope that a user