From ba2d8663f14f02bf52529ec353754d784b0a04db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 13 Nov 2024 17:54:20 +0100 Subject: [PATCH 1/4] Add line splitting in user render string functionality Introduce a new option to split lines in user job and scope render strings based on a specified character limit. Implement a corresponding test to verify the correct behavior of this feature. --- .../Templating/Entity/UserRender.php | 32 ++++++++++-- .../Templating/Entity/UserRenderTest.php | 49 ++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/UserRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/UserRender.php index 053e8d611..50fe26a3f 100644 --- a/src/Bundle/ChillMainBundle/Templating/Entity/UserRender.php +++ b/src/Bundle/ChillMainBundle/Templating/Entity/UserRender.php @@ -13,8 +13,6 @@ namespace Chill\MainBundle\Templating\Entity; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; -use DateTime; -use DateTimeImmutable; use Symfony\Component\Clock\ClockInterface; use Symfony\Contracts\Translation\TranslatorInterface; use Twig\Error\LoaderError; @@ -26,11 +24,17 @@ use Twig\Error\SyntaxError; */ class UserRender implements ChillEntityRenderInterface { + public const SPLIT_LINE_BEFORE_CHARACTER = 'split_lines_before_characters'; final public const DEFAULT_OPTIONS = [ 'main_scope' => true, 'user_job' => true, 'absence' => true, 'at_date' => null, // instanceof DateTimeInterface + /* + * when set, the jobs and service will be splitted in multiple lines. The line will be splitted + * before the given character. Only for renderString, renderBox is not concerned. + */ + self::SPLIT_LINE_BEFORE_CHARACTER => null, ]; public function __construct( @@ -65,8 +69,6 @@ class UserRender implements ChillEntityRenderInterface { $opts = \array_merge(self::DEFAULT_OPTIONS, $options); - // $immutableAtDate = $opts['at_date'] instanceOf DateTime ? DateTimeImmutable::createFromMutable($opts['at_date']) : $opts['at_date']; - if (null === $opts['at_date']) { $opts['at_date'] = $this->clock->now(); } elseif ($opts['at_date'] instanceof \DateTime) { @@ -89,6 +91,28 @@ class UserRender implements ChillEntityRenderInterface $str .= ' ('.$this->translator->trans('absence.Absent').')'; } + if (null !== $opts[self::SPLIT_LINE_BEFORE_CHARACTER]) { + if (!is_int($opts[self::SPLIT_LINE_BEFORE_CHARACTER])) { + throw new \InvalidArgumentException('Only integer for option split_lines_before_characters is allowed'); + } + + $characterPerLine = $opts[self::SPLIT_LINE_BEFORE_CHARACTER]; + $exploded = explode(' ', $str); + $charOnLine = 0; + $str = ''; + foreach ($exploded as $word) { + if ($charOnLine + strlen($word) > $characterPerLine) { + $str .= "\n"; + $charOnLine = 0; + } + if ($charOnLine > 0) { + $str .= ' '; + } + $str .= $word; + $charOnLine += strlen($word); + } + } + return $str; } diff --git a/src/Bundle/ChillMainBundle/Tests/Templating/Entity/UserRenderTest.php b/src/Bundle/ChillMainBundle/Tests/Templating/Entity/UserRenderTest.php index 2b0e07732..b22580569 100644 --- a/src/Bundle/ChillMainBundle/Tests/Templating/Entity/UserRenderTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Templating/Entity/UserRenderTest.php @@ -35,7 +35,6 @@ class UserRenderTest extends TestCase public function testRenderUserWithJobAndScopeAtCertainDate(): void { // Create a user with a certain user job - $user = new User(); $userJobA = new UserJob(); $scopeA = new Scope(); @@ -106,4 +105,52 @@ class UserRenderTest extends TestCase $expectedStringC = 'BOB ISLA (directrice) (service B)'; $this->assertEquals($expectedStringC, $renderer->renderString($user, $optionsNoDate)); } + + public function testRenderStringWithSplitLines(): void + { + + // Create a user with a certain user job + $user = new User(); + $userJobA = new UserJob(); + $scopeA = new Scope(); + + $userJobA->setLabel(['fr' => 'assistant social en maison de service accompagné']) + ->setActive(true); + $scopeA->setName(['fr' => 'service de l\'assistant professionnel']); + $user->setLabel('Robert Van Zorrizzeen Gorikke'); + + $userJobHistoryA = (new User\UserJobHistory()) + ->setUser($user) + ->setJob($userJobA) + ->setStartDate(new \DateTimeImmutable('2023-11-01 12:00:00')) + ->setEndDate(new \DateTimeImmutable('2023-11-30 00:00:00')); + + $userScopeHistoryA = (new User\UserScopeHistory()) + ->setUser($user) + ->setScope($scopeA) + ->setStartDate(new \DateTimeImmutable('2023-11-01 12:00:00')) + ->setEndDate(new \DateTimeImmutable('2023-11-30 00:00:00')); + + $user->getUserJobHistories()->add($userJobHistoryA); + $user->getUserScopeHistories()->add($userScopeHistoryA); + + // Create renderer + $translatableStringHelperMock = $this->prophesize(TranslatableStringHelperInterface::class); + $translatableStringHelperMock->localize(Argument::type('array'))->will(fn ($args) => $args[0]['fr']); + + $engineMock = $this->createMock(Environment::class); + $translatorMock = $this->createMock(TranslatorInterface::class); + $clock = new MockClock(new \DateTimeImmutable('2023-11-15 12:00:00')); + + $renderer = new UserRender($translatableStringHelperMock->reveal(), $engineMock, $translatorMock, $clock); + + $actual = $renderer->renderString($user, ['split_lines_before_characters' => 30]); + self::assertEquals(<<<'STR' + Robert Van Zorrizzeen Gorikke + (assistant social en maison de + service accompagné) (service de + l'assistant professionnel) + STR, $actual); + + } } From d50b169ab8536a08b609d7d1b8bf5108421b6001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 13 Nov 2024 17:54:28 +0100 Subject: [PATCH 2/4] Add UserRender option to SignatureRequestController Integrated a new UserRender option 'SPLIT_LINE_BEFORE_CHARACTER' with a value of 30 in the SignatureRequestController. This enhances the rendering format for user details. --- .../Controller/SignatureRequestController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php b/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php index c6c564be1..b443779bb 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/SignatureRequestController.php @@ -19,6 +19,7 @@ use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface; use Chill\MainBundle\Security\Authorization\EntityWorkflowStepSignatureVoter; +use Chill\MainBundle\Templating\Entity\UserRender; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -75,6 +76,7 @@ class SignatureRequestController // options for user render 'absence' => false, 'main_scope' => false, + UserRender::SPLIT_LINE_BEFORE_CHARACTER => 30, // options for person render 'addAge' => false, ]), From c99dda012679ad8e7f6a8de3eb13430eb7cafde1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 13 Nov 2024 22:41:23 +0100 Subject: [PATCH 3/4] Rename WorkflowStoredObjectPermissionHelper to WorkflowRelatedEntityPermissionHelper and create method isAllowedByWorkflow Refactored class names and namespaces from WorkflowStoredObjectPermissionHelper to WorkflowRelatedEntityPermissionHelper across various modules. Updated associated tests and voter classes to reflect the changes, ensuring consistency and clarity in the codebase. --- .../ActivityStoredObjectVoter.php | 4 +- .../AbstractStoredObjectVoter.php | 4 +- ...panyingCourseDocumentStoredObjectVoter.php | 4 +- .../PersonDocumentStoredObjectVoter.php | 4 +- .../AbstractStoredObjectVoterTest.php | 10 +-- .../Authorization/EventStoredObjectVoter.php | 4 +- ...flowRelatedEntityPermissionHelperTest.php} | 71 +++++++++++++++++-- ...WorkflowRelatedEntityPermissionHelper.php} | 36 +++++++++- ...orkEvaluationDocumentStoredObjectVoter.php | 4 +- 9 files changed, 117 insertions(+), 24 deletions(-) rename src/Bundle/{ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php => ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php} (68%) rename src/Bundle/{ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php => ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php} (65%) diff --git a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php index 78def7318..6a2a27140 100644 --- a/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php +++ b/src/Bundle/ChillActivityBundle/Security/Authorization/ActivityStoredObjectVoter.php @@ -16,7 +16,7 @@ use Chill\ActivityBundle\Repository\ActivityRepository; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Symfony\Component\Security\Core\Security; class ActivityStoredObjectVoter extends AbstractStoredObjectVoter @@ -24,7 +24,7 @@ class ActivityStoredObjectVoter extends AbstractStoredObjectVoter public function __construct( private readonly ActivityRepository $repository, Security $security, - WorkflowStoredObjectPermissionHelper $workflowDocumentService, + WorkflowRelatedEntityPermissionHelper $workflowDocumentService, ) { parent::__construct($security, $workflowDocumentService); } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php index 9d77211c2..1b9378e72 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php @@ -15,7 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Security; @@ -34,7 +34,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface public function __construct( private readonly Security $security, - private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null, + private readonly ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null, ) {} public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AccompanyingCourseDocumentStoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AccompanyingCourseDocumentStoredObjectVoter.php index 94ff9149d..e1a9add7d 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AccompanyingCourseDocumentStoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AccompanyingCourseDocumentStoredObjectVoter.php @@ -16,7 +16,7 @@ use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Symfony\Component\Security\Core\Security; final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter @@ -24,7 +24,7 @@ final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredOb public function __construct( private readonly AccompanyingCourseDocumentRepository $repository, Security $security, - WorkflowStoredObjectPermissionHelper $workflowDocumentService, + WorkflowRelatedEntityPermissionHelper $workflowDocumentService, ) { parent::__construct($security, $workflowDocumentService); } diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/PersonDocumentStoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/PersonDocumentStoredObjectVoter.php index 4c8ae693e..16833c535 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/PersonDocumentStoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/PersonDocumentStoredObjectVoter.php @@ -16,7 +16,7 @@ use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Repository\PersonDocumentRepository; use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Symfony\Component\Security\Core\Security; class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter @@ -24,7 +24,7 @@ class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter public function __construct( private readonly PersonDocumentRepository $repository, Security $security, - WorkflowStoredObjectPermissionHelper $workflowDocumentService, + WorkflowRelatedEntityPermissionHelper $workflowDocumentService, ) { parent::__construct($security, $workflowDocumentService); } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php index a045a1fd6..6fbb9c2e4 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php @@ -15,7 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; use PHPUnit\Framework\TestCase; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -30,16 +30,16 @@ class AbstractStoredObjectVoterTest extends TestCase { private AssociatedEntityToStoredObjectInterface $repository; private Security $security; - private WorkflowStoredObjectPermissionHelper $workflowDocumentService; + private WorkflowRelatedEntityPermissionHelper $workflowDocumentService; protected function setUp(): void { $this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class); $this->security = $this->createMock(Security::class); - $this->workflowDocumentService = $this->createMock(WorkflowStoredObjectPermissionHelper::class); + $this->workflowDocumentService = $this->createMock(WorkflowRelatedEntityPermissionHelper::class); } - private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter + private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter { // Anonymous class extending the abstract class return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter { @@ -47,7 +47,7 @@ class AbstractStoredObjectVoterTest extends TestCase private readonly bool $canBeAssociatedWithWorkflow, private readonly AssociatedEntityToStoredObjectInterface $repository, Security $security, - ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null, + ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null, ) { parent::__construct($security, $workflowDocumentService); } diff --git a/src/Bundle/ChillEventBundle/Security/Authorization/EventStoredObjectVoter.php b/src/Bundle/ChillEventBundle/Security/Authorization/EventStoredObjectVoter.php index 9a23d6d85..7e61db7d6 100644 --- a/src/Bundle/ChillEventBundle/Security/Authorization/EventStoredObjectVoter.php +++ b/src/Bundle/ChillEventBundle/Security/Authorization/EventStoredObjectVoter.php @@ -14,7 +14,7 @@ namespace Chill\EventBundle\Security\Authorization; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\EventBundle\Entity\Event; use Chill\EventBundle\Repository\EventRepository; use Chill\EventBundle\Security\EventVoter; @@ -25,7 +25,7 @@ class EventStoredObjectVoter extends AbstractStoredObjectVoter public function __construct( private readonly EventRepository $repository, Security $security, - WorkflowStoredObjectPermissionHelper $workflowDocumentService, + WorkflowRelatedEntityPermissionHelper $workflowDocumentService, ) { parent::__construct($security, $workflowDocumentService); } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php similarity index 68% rename from src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php rename to src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php index 9cfd55c57..3e4857d57 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Service/WorkflowStoredObjectPermissionHelperTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php @@ -9,9 +9,9 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\DocStoreBundle\Tests\Service; +namespace Chill\MainBundle\Tests\Workflow\Helper; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; @@ -36,7 +36,7 @@ use Symfony\Component\Workflow\WorkflowInterface; * * @coversNothing */ -class WorkflowStoredObjectPermissionHelperTest extends TestCase +class WorkflowRelatedEntityPermissionHelperTest extends TestCase { use ProphecyTrait; @@ -53,6 +53,19 @@ class WorkflowStoredObjectPermissionHelperTest extends TestCase self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message); } + /** + * @dataProvider provideDataAllowedByWorkflow + */ + public function testAllowedByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void + { + // all entities must have this workflow name, so we are ok to set it here + $entityWorkflow->setWorkflowName('dummy'); + $object = new \stdClass(); + $helper = $this->buildHelper($object, $entityWorkflow, $user); + + self::assertEquals($expected, $helper->isAllowedByWorkflow($entityWorkflow), $message); + } + public function testNoWorkflow(): void { $object = new \stdClass(); @@ -60,7 +73,7 @@ class WorkflowStoredObjectPermissionHelperTest extends TestCase self::assertTrue($helper->notBlockedByWorkflow($object), "the user is not blocked by the user, as there aren't any user inside"); } - private function buildHelper(object $relatedEntity, ?EntityWorkflow $entityWorkflow, User $user): WorkflowStoredObjectPermissionHelper + private function buildHelper(object $relatedEntity, ?EntityWorkflow $entityWorkflow, User $user): WorkflowRelatedEntityPermissionHelper { $security = $this->prophesize(Security::class); $security->getUser()->willReturn($user); @@ -72,7 +85,55 @@ class WorkflowStoredObjectPermissionHelperTest extends TestCase $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([]); } - return new WorkflowStoredObjectPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry()); + return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry()); + } + + public static function provideDataAllowedByWorkflow(): iterable + { + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [$entityWorkflow, new User(), false, 'not allowed because the user is not present as a dest user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [$entityWorkflow, $user, true, 'allowed because the user is a current user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [$entityWorkflow, $user, true, 'allowed because the user was a previous user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User()); + $entityWorkflow->getCurrentStep()->setIsFinal(true); + + yield [$entityWorkflow, $user, false, 'not allowed because: user was a previous user, but it is finalized positive']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable()); + $entityWorkflow->getCurrentStep()->setIsFinal(true); + + yield [$entityWorkflow, $user, true, 'allowed: user was a previous user, it is finalized, but finalized negative']; + } public static function provideDataNotBlockByWorkflow(): iterable diff --git a/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php similarity index 65% rename from src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php rename to src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php index 95cc77194..472ab93ee 100644 --- a/src/Bundle/ChillDocStoreBundle/Service/WorkflowStoredObjectPermissionHelper.php +++ b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php @@ -9,7 +9,7 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Chill\DocStoreBundle\Service; +namespace Chill\MainBundle\Workflow\Helper; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Workflow\EntityWorkflowManager; @@ -19,7 +19,7 @@ use Symfony\Component\Workflow\Registry; /** * Check if an object, associated with a workflow, is blocked, or not, by this workflow. */ -class WorkflowStoredObjectPermissionHelper +class WorkflowRelatedEntityPermissionHelper { public function __construct( private readonly Security $security, @@ -27,6 +27,38 @@ class WorkflowStoredObjectPermissionHelper private readonly Registry $registry, ) {} + public function isAllowedByWorkflow(object $entity): bool + { + $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); + $currentUser = $this->security->getUser(); + + foreach ($entityWorkflows as $entityWorkflow) { + // if the user is finalized, we have to check if the workflow is finalPositive, or not + if ($entityWorkflow->isFinal()) { + $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); + $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); + foreach ($marking->getPlaces() as $place => $int) { + $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); + if (true === ($placeMetadata['isFinalPositive'] ?? false)) { + // the workflow is final, and final positive, so we stop here. + return false; + } + } + } + } + + foreach ($entityWorkflows as $entityWorkflow) { + // so, the workflow is running... We return true if the current user is involved + foreach ($entityWorkflow->getSteps() as $step) { + if ($step->getAllDestUser()->contains($currentUser)) { + return true; + } + } + } + + return false; + } + /** * Return true if the user is allowed to update the given object. * diff --git a/src/Bundle/ChillPersonBundle/Security/Authorization/StoredObjectVoter/AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter.php b/src/Bundle/ChillPersonBundle/Security/Authorization/StoredObjectVoter/AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter.php index e2ede26e5..4137cb077 100644 --- a/src/Bundle/ChillPersonBundle/Security/Authorization/StoredObjectVoter/AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter.php +++ b/src/Bundle/ChillPersonBundle/Security/Authorization/StoredObjectVoter/AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter.php @@ -14,7 +14,7 @@ namespace Chill\PersonBundle\Security\Authorization\StoredObjectVoter; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; -use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument; use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocumentRepository; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkEvaluationDocumentVoter; @@ -25,7 +25,7 @@ class AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter extends Abstract public function __construct( private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository, Security $security, - WorkflowStoredObjectPermissionHelper $workflowDocumentService, + WorkflowRelatedEntityPermissionHelper $workflowDocumentService, ) { parent::__construct($security, $workflowDocumentService); } From 829fb669febe5b020e0a435e659835ec3416d78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 13 Nov 2024 22:41:30 +0100 Subject: [PATCH 4/4] Update Workflow Permission Handling Refactor the `WorkflowRelatedEntityPermissionHelper` to enhance permission checks for workflow-related entities. This includes updating methods, improving test coverage, and incorporating `MockClock` for date-sensitive operations. --- .../AbstractStoredObjectVoter.php | 27 ++- .../AbstractStoredObjectVoterTest.php | 172 +++++++------- ...kflowRelatedEntityPermissionHelperTest.php | 222 +++++++++++------- .../WorkflowRelatedEntityPermissionHelper.php | 163 ++++++++----- 4 files changed, 335 insertions(+), 249 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php index 1b9378e72..161360c52 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php @@ -46,24 +46,27 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool { - // Retrieve the related accompanying course document + // Retrieve the related entity $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); - // Determine the attribute to pass to AccompanyingCourseDocumentVoter + // Determine the attribute to pass to the voter for argument $voterAttribute = $this->attributeToRole($attribute); - if (false === $this->security->isGranted($voterAttribute, $entity)) { - return false; + $regularPermission = $this->security->isGranted($voterAttribute, $entity); + + if (!$this->canBeAssociatedWithWorkflow()) { + return $regularPermission; } - if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) { - if (null === $this->workflowDocumentService) { - throw new \LogicException('Provide a workflow document service'); - } + $workflowPermission = match ($attribute) { + StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entity), + StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entity), + }; - return $this->workflowDocumentService->notBlockedByWorkflow($entity); - } - - return true; + return match ($workflowPermission) { + WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, + WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, + WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, + }; } } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php index 6fbb9c2e4..fc6099557 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php @@ -15,10 +15,11 @@ use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter; -use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Security; /** @@ -28,19 +29,14 @@ use Symfony\Component\Security\Core\Security; */ class AbstractStoredObjectVoterTest extends TestCase { - private AssociatedEntityToStoredObjectInterface $repository; - private Security $security; - private WorkflowRelatedEntityPermissionHelper $workflowDocumentService; + use ProphecyTrait; - protected function setUp(): void - { - $this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class); - $this->security = $this->createMock(Security::class); - $this->workflowDocumentService = $this->createMock(WorkflowRelatedEntityPermissionHelper::class); - } - - private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter - { + private function buildStoredObjectVoter( + bool $canBeAssociatedWithWorkflow, + AssociatedEntityToStoredObjectInterface $repository, + Security $security, + ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null, + ): AbstractStoredObjectVoter { // Anonymous class extending the abstract class return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter { public function __construct( @@ -74,95 +70,89 @@ class AbstractStoredObjectVoterTest extends TestCase }; } - private function setupMockObjects(): array - { - $user = new User(); - $token = $this->createMock(TokenInterface::class); - $subject = new StoredObject(); - $entity = new \stdClass(); - - return [$user, $token, $subject, $entity]; - } - - private function setupMocksForVoteOnAttribute(User $user, TokenInterface $token, bool $isGrantedForEntity, object $entity, bool $workflowAllowed): void - { - // Set up token to return user - $token->method('getUser')->willReturn($user); - - // Mock the return of an AccompanyingCourseDocument by the repository - $this->repository->method('findAssociatedEntityToStoredObject')->willReturn($entity); - - // Mock scenario where user is allowed to see_details of the AccompanyingCourseDocument - $this->security->method('isGranted')->willReturn($isGrantedForEntity); - - // Mock case where user is blocked or not by workflow - $this->workflowDocumentService->method('notBlockedByWorkflow')->willReturn($workflowAllowed); - } - public function testSupportsOnAttribute(): void { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null); - // Setup mocks for voteOnAttribute method - $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); + self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject())); - self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject)); + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null); + + self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject())); + + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null); + + self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject())); } - public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void - { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + /** + * @dataProvider dataProviderVoteOnAttribute + */ + public function testVoteOnAttribute( + StoredObjectRoleEnum $attribute, + bool $expected, + bool $canBeAssociatedWithWorkflow, + bool $isGrantedRegularPermission, + ?string $isGrantedWorkflowPermissionRead, + ?string $isGrantedWorkflowPermissionWrite, + string $message, + ): void { + $storedObject = new StoredObject(); + $dummyRepository = new DummyRepository($related = new \stdClass()); + $token = new UsernamePasswordToken(new User(), 'dummy'); - // Setup mocks for voteOnAttribute method - $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); + $security = $this->prophesize(Security::class); + $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); - // The voteOnAttribute method should return True when workflow is allowed - self::assertTrue($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); + $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); + if (null !== $isGrantedWorkflowPermissionRead) { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) + ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); + } else { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)->shouldNotBeCalled(); + } + + if (null !== $isGrantedWorkflowPermissionWrite) { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related) + ->willReturn($isGrantedWorkflowPermissionWrite)->shouldBeCalled(); + } else { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)->shouldNotBeCalled(); + } + + $voter = $this->buildStoredObjectVoter($canBeAssociatedWithWorkflow, $dummyRepository, $security->reveal(), $workflowRelatedEntityPermissionHelper->reveal()); + self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); } - public function testVoteOnAttributeNotAllowed(): void + public static function dataProviderVoteOnAttribute(): iterable { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + // not associated on a workflow + yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; + yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper']; - // Setup mocks for voteOnAttribute method where isGranted() returns false - $this->setupMocksForVoteOnAttribute($user, $token, false, $entity, true); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); + // associated on a workflow, read operation + yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied']; + yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied']; - // The voteOnAttribute method should return True when workflow is allowed - self::assertFalse($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); - } - - public function testVoteOnAttributeAllowedWorkflowNotAllowed(): void - { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); - - // Setup mocks for voteOnAttribute method - $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); - - // Test voteOnAttribute method - $attribute = StoredObjectRoleEnum::EDIT; - $result = $voter->voteOnAttribute($attribute, $subject, $token); - - // Assert that access is denied when workflow is not allowed - $this->assertFalse($result); - } - - public function testVoteOnAttributeAllowedWorkflowAllowedToSeeDocument(): void - { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); - - // Setup mocks for voteOnAttribute method - $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); - - // Test voteOnAttribute method - $attribute = StoredObjectRoleEnum::SEE; - $result = $voter->voteOnAttribute($attribute, $subject, $token); - - // Assert that access is denied when workflow is not allowed - $this->assertTrue($result); + // association on a workflow, write operation + yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied']; + yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied']; + } +} + +class DummyRepository implements AssociatedEntityToStoredObjectInterface +{ + public function __construct(private readonly ?object $relatedEntity) {} + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object + { + return $this->relatedEntity; } } diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php index 3e4857d57..fc06a533f 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php @@ -15,7 +15,6 @@ use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; -use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; @@ -23,6 +22,7 @@ use Chill\PersonBundle\Entity\Person; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\DefinitionBuilder; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; @@ -41,67 +41,85 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase use ProphecyTrait; /** - * @dataProvider provideDataNotBlockByWorkflow + * @dataProvider provideDataAllowedByWorkflowReadOperation + * + * @param list $entityWorkflows */ - public function testNotBlockByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void - { + public function testAllowedByWorkflowRead( + array $entityWorkflows, + User $user, + string $expected, + ?\DateTimeImmutable $atDate, + string $message, + ): void { // all entities must have this workflow name, so we are ok to set it here - $entityWorkflow->setWorkflowName('dummy'); - $object = new \stdClass(); - $helper = $this->buildHelper($object, $entityWorkflow, $user); + foreach ($entityWorkflows as $entityWorkflow) { + $entityWorkflow->setWorkflowName('dummy'); + } + $helper = $this->buildHelper($entityWorkflows, $user, $atDate); - self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message); + self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new \stdClass()), $message); } /** - * @dataProvider provideDataAllowedByWorkflow + * @dataProvider provideDataAllowedByWorkflowWriteOperation + * + * @param list $entityWorkflows */ - public function testAllowedByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void - { + public function testAllowedByWorkflowWrite( + array $entityWorkflows, + User $user, + string $expected, + ?\DateTimeImmutable $atDate, + string $message, + ): void { // all entities must have this workflow name, so we are ok to set it here - $entityWorkflow->setWorkflowName('dummy'); - $object = new \stdClass(); - $helper = $this->buildHelper($object, $entityWorkflow, $user); + foreach ($entityWorkflows as $entityWorkflow) { + $entityWorkflow->setWorkflowName('dummy'); + } + $helper = $this->buildHelper($entityWorkflows, $user, $atDate); - self::assertEquals($expected, $helper->isAllowedByWorkflow($entityWorkflow), $message); + self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new \stdClass()), $message); } public function testNoWorkflow(): void { - $object = new \stdClass(); - $helper = $this->buildHelper($object, null, $user = new User()); - self::assertTrue($helper->notBlockedByWorkflow($object), "the user is not blocked by the user, as there aren't any user inside"); + $helper = $this->buildHelper([], new User(), null); + + self::assertEquals(WorkflowRelatedEntityPermissionHelper::ABSTAIN, $helper->isAllowedByWorkflowForWriteOperation(new \stdClass())); + self::assertEquals(WorkflowRelatedEntityPermissionHelper::ABSTAIN, $helper->isAllowedByWorkflowForReadOperation(new \stdClass())); } - private function buildHelper(object $relatedEntity, ?EntityWorkflow $entityWorkflow, User $user): WorkflowRelatedEntityPermissionHelper + /** + * @param list $entityWorkflows + */ + private function buildHelper(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper { $security = $this->prophesize(Security::class); $security->getUser()->willReturn($user); $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); - if (null !== $entityWorkflow) { - $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([$entityWorkflow]); - } else { - $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([]); - } + $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); - return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry()); + return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); } - public static function provideDataAllowedByWorkflow(): iterable + public static function provideDataAllowedByWorkflowReadOperation(): iterable { $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, new User(), false, 'not allowed because the user is not present as a dest user']; + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain because the user is not present as a dest user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user is a current user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user is a current user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -112,7 +130,68 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $dto->futureDestUsers[] = new User(); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user was a previous user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user was a previous user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because there is a signature for person']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable('2024-01-01T12:00:00')); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable('2024-01-10T12:00:00'), + 'abstain because there is a signature for person, already signed, and for a long time ago']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED) + ->setStateDate(new \DateTimeImmutable('2024-01-01T12:00:00')); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, + new \DateTimeImmutable('2024-01-01T12:30:00'), + 'force grant because there is a signature for person, already signed, a short time ago']; + } + + public static function provideDataAllowedByWorkflowWriteOperation(): iterable + { + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain because the user is not present as a dest user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user is a current user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user was a previous user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -122,7 +201,8 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, false, 'not allowed because: user was a previous user, but it is finalized positive']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), + 'force denied: user was a previous user, but it is finalized positive']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -132,80 +212,48 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, true, 'allowed: user was a previous user, it is finalized, but finalized negative']; - - } - - public static function provideDataNotBlockByWorkflow(): iterable - { - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); - - yield [$entityWorkflow, new User(), false, 'blocked because the user is not present as a dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain: user was a previous user, it is finalized, but finalized negative']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); - yield [$entityWorkflow, $user, true, 'allowed because the user is present as a dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), + 'force denied because there is a signature']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user is present as a **previous** dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant: there is a signature, but still pending']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, false, 'blocked because the step is final, and final positive']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user); - $entityWorkflow->getCurrentStep()->setIsFinal(true); - - yield [$entityWorkflow, $user, true, 'allowed because the step is final, and final negative']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - new EntityWorkflowStepSignature($step, new Person()); - - yield [$entityWorkflow, $user, true, 'allow, a signature is present but still pending']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - $signature = new EntityWorkflowStepSignature($step, new Person()); - $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); - - yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - $signature = new EntityWorkflowStepSignature($step, new Person()); - $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); - - yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed, although the workflow is final negative']; - + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain: there is a signature on a canceled workflow']; } private static function buildRegistry(): Registry diff --git a/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php index 472ab93ee..cd890154e 100644 --- a/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php +++ b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php @@ -11,29 +11,100 @@ declare(strict_types=1); namespace Chill\MainBundle\Workflow\Helper; +use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\Registry; /** - * Check if an object, associated with a workflow, is blocked, or not, by this workflow. + * Helper to give supplementary permissions to a related entity. + * + * If a related entity is associated within a workflow, the logic of the workflow can give more permissions, or + * remove some permissions. + * + * The methods of this helper return either: + * + * - FORCE_GRANT, which means that a permission can be given, even if it would be denied when the related + * entity is not associated with a workflow; + * - FORCE_DENIED, which means that a permission should be denied, even if it would be granted when the related entity + * is not associated with a workflow + * - ABSTAIN, if there is no workflow logic to add or remove permission + * + * For read operations: + * + * - if the user is involved in the workflow (is part of the current step, of a step before), the user is granted read + * operation; + * - if there is a pending signature for a person, the workflow grant access to the related entity; + * - if there a signature applyied in less than 12 hours, the workflow grant access to the related entity. This allow to + * show the related entity to the person during this time frame. + * + * + * For write operation: + * + * - if the workflow is finalized "positive" (means "not canceled"), the workflow denys write operations; + * - if there isn't any finalized "positive" workflow, and if there is a signature appliyed for a running workflow (not finalized nor canceled), + * the workflow denys write operations; + * - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted; */ class WorkflowRelatedEntityPermissionHelper { + public const FORCE_GRANT = 'FORCE_GRANT'; + public const FORCE_DENIED = 'FORCE_DENIED'; + public const ABSTAIN = 'ABSTAIN'; + public function __construct( private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager, private readonly Registry $registry, + private readonly ClockInterface $clock, ) {} - public function isAllowedByWorkflow(object $entity): bool + /** + * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' + */ + public function isAllowedByWorkflowForReadOperation(object $entity): string { $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); - $currentUser = $this->security->getUser(); + + if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { + return self::FORCE_GRANT; + } + + // give a view permission if there is a Person signature pending, or in the 12 hours following + // the signature last state + foreach ($entityWorkflows as $workflow) { + foreach ($workflow->getCurrentStep()->getSignatures() as $signature) { + if ('person' === $signature->getSignerKind()) { + if (EntityWorkflowSignatureStateEnum::PENDING === $signature->getState()) { + return self::FORCE_GRANT; + } + $signatureDate = $signature->getStateDate(); + $visibleUntil = $signatureDate->add(new \DateInterval('PT12H')); + + if ($visibleUntil > $this->clock->now()) { + return self::FORCE_GRANT; + } + + } + } + } + + return self::ABSTAIN; + } + + /** + * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' + */ + public function isAllowedByWorkflowForWriteOperation(object $entity): string + { + $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); + $runningWorkflows = []; + + // if a workflow is finalized positive, we are not allowed to edit to document any more foreach ($entityWorkflows as $entityWorkflow) { - // if the user is finalized, we have to check if the workflow is finalPositive, or not if ($entityWorkflow->isFinal()) { $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); @@ -41,12 +112,40 @@ class WorkflowRelatedEntityPermissionHelper $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); if (true === ($placeMetadata['isFinalPositive'] ?? false)) { // the workflow is final, and final positive, so we stop here. - return false; + return self::FORCE_DENIED; + } + } + } else { + $runningWorkflows[] = $entityWorkflow; + } + } + + // if there is a signature on a **running workflow**, no one can edit the workflow any more + foreach ($runningWorkflows as $entityWorkflow) { + foreach ($entityWorkflow->getSteps() as $step) { + foreach ($step->getSignatures() as $signature) { + if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { + return self::FORCE_DENIED; } } } } + // allow only the users involved + if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) { + return self::FORCE_GRANT; + } + + return self::ABSTAIN; + } + + /** + * @param list $entityWorkflows + */ + private function isUserInvolvedInAWorkflow(array $entityWorkflows): bool + { + $currentUser = $this->security->getUser(); + foreach ($entityWorkflows as $entityWorkflow) { // so, the workflow is running... We return true if the current user is involved foreach ($entityWorkflow->getSteps() as $step) { @@ -58,58 +157,4 @@ class WorkflowRelatedEntityPermissionHelper return false; } - - /** - * Return true if the user is allowed to update the given object. - * - * Return false if some workflow block the edition of the object. - */ - public function notBlockedByWorkflow(object $entity): bool - { - $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); - $currentUser = $this->security->getUser(); - - $usersInvolved = []; - $entityWorkflowsNotFinalizedPositive = []; - foreach ($entityWorkflows as $entityWorkflow) { - // as soon as there is one signatured applyied, we are not able to - // edit the document any more - foreach ($entityWorkflow->getSteps() as $step) { - foreach ($step->getSignatures() as $signature) { - if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { - return false; - } - } - } - - if ($entityWorkflow->isFinal()) { - $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); - $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); - foreach ($marking->getPlaces() as $place => $active) { - $metadata = $workflow->getMetadataStore()->getPlaceMetadata($place); - if ($metadata['isFinalPositive'] ?? true) { - return false; - } - } - } else { - $entityWorkflowsNotFinalizedPositive[] = $entityWorkflow; - foreach ($entityWorkflow->getSteps() as $step) { - foreach ($step->getAllDestUser()->toArray() as $user) { - $usersInvolved[] = $user; - } - } - } - } - - // if there isn't any user, but a workflow, blocked - if ([] !== $entityWorkflowsNotFinalizedPositive) { - if ([] === $usersInvolved) { - return false; - } - - return in_array($currentUser, $usersInvolved, true); - } - - return true; - } }