From cf2fe1bba792b22ff7242acb368d092a5fd31cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 24 Sep 2024 10:59:33 +0200 Subject: [PATCH] Add guards and tests for entity workflow transitions Introduced EntityWorkflowGuardUnsignedTransition to block transitions with pending signatures. Implemented a new center resolver and added comprehensive unit tests for verifying transition rules and permissions. --- .../EntityWorkflowTransitionVoter.php | 7 +- ...ityWorkflowGuardUnsignedTransitionTest.php | 203 ++++++++++++++++++ .../EntityWorkflowGuardUnsignedTransition.php | 69 ++++++ ...odWorkEvaluationDocumentCenterResolver.php | 40 ++++ 4 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransitionTest.php create mode 100644 src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransition.php create mode 100644 src/Bundle/ChillPersonBundle/Security/CenterResolver/AccompanyingPeriodWorkEvaluationDocumentCenterResolver.php diff --git a/src/Bundle/ChillMainBundle/Security/Authorization/EntityWorkflowTransitionVoter.php b/src/Bundle/ChillMainBundle/Security/Authorization/EntityWorkflowTransitionVoter.php index 811c45f64..2c89f563a 100644 --- a/src/Bundle/ChillMainBundle/Security/Authorization/EntityWorkflowTransitionVoter.php +++ b/src/Bundle/ChillMainBundle/Security/Authorization/EntityWorkflowTransitionVoter.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Security\Authorization; +use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep; use Chill\MainBundle\Security\ProvideRoleHierarchyInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; @@ -55,13 +56,13 @@ final class EntityWorkflowTransitionVoter extends Voter implements ProvideRoleHi protected function supports(string $attribute, $subject): bool { - return self::APPLY_ALL_TRANSITIONS === $attribute && $subject instanceof EntityWorkflowStep; + return self::APPLY_ALL_TRANSITIONS === $attribute && ($subject instanceof EntityWorkflowStep || $subject instanceof EntityWorkflow); } protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool { - /** @var EntityWorkflowStep $subject */ - $entityWorkflow = $subject->getEntityWorkflow(); + /** @var EntityWorkflowStep|EntityWorkflow $subject */ + $entityWorkflow = $subject instanceof EntityWorkflowStep ? $subject->getEntityWorkflow() : $subject; if (!$this->accessDecisionManager->decide($token, [EntityWorkflowVoter::SEE], $entityWorkflow)) { return false; diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransitionTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransitionTest.php new file mode 100644 index 000000000..6a27e723a --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransitionTest.php @@ -0,0 +1,203 @@ +prophesize(ChillEntityRenderManagerInterface::class); + $chillEntityRender->renderString(Argument::type('object'), Argument::type('array'))->will(fn ($args) => spl_object_hash($args[0])); + + $security = $this->prophesize(Security::class); + if ([] !== $expectedErrors) { + $security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow) + ->shouldBeCalled() + ->willReturn(false); + } + + $registry = self::buildRegistry($chillEntityRender->reveal(), $security->reveal()); + + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $actual = $workflow->buildTransitionBlockerList($entityWorkflow, $transition); + + self::assertCount(count($expectedErrors), $actual, $message); + $blockers = iterator_to_array($actual->getIterator()); + + if ([] !== $expectedErrors) { + foreach ($expectedErrors as $k => $expectedError) { + self::assertcontains($expectedError, array_map(fn (TransitionBlocker $blocker) => $blocker->getCode(), $blockers)); + self::assertEquals($expectedParameters[$k], $blockers[$k]->getParameters()); + } + } + } + + /** + * @dataProvider guardWaitingForSignatureWithPermissionToApplyAllTransitionsProvider + */ + public function testGuardWaitingForSignatureWithPermissionToApplyAllTransitions(EntityWorkflow $entityWorkflow, string $transition, bool $expectIsGranted, string $message) + { + $chillEntityRender = $this->prophesize(ChillEntityRenderManagerInterface::class); + $chillEntityRender->renderString(Argument::type('object'), Argument::type('array'))->will(fn ($args) => spl_object_hash($args[0])); + + $security = $this->prophesize(Security::class); + $isGranted = $security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, Argument::type(EntityWorkflow::class)); + if ($expectIsGranted) { + $isGranted->shouldBeCalled(); + } + $isGranted->willReturn(true); + + $registry = self::buildRegistry($chillEntityRender->reveal(), $security->reveal()); + + $workflow = $registry->get($entityWorkflow, 'dummy'); + + $actual = $workflow->buildTransitionBlockerList($entityWorkflow, $transition); + + self::assertCount(0, $actual, $message); + } + + public static function guardWaitingForSignatureWithPermissionToApplyAllTransitionsProvider(): iterable + { + $registry = self::buildRegistry(); + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers = [$user = new User()]; + $dto->futureUserSignature = $user; + + $workflow = $registry->get($entityWorkflow, 'dummy'); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']); + + yield [$entityWorkflow, 'to_post-signature', true, 'A transition forward is allowed, even if a signature is pending, because the user has permission to apply all transition']; + yield [$entityWorkflow, 'to_cancel', false, 'A transition backward is allowed, even if a signature is pending']; + } + + public static function guardWaitingForSignatureWithoutPermissionToApplyAllTransitionsProvider(): iterable + { + $registry = self::buildRegistry(); + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers = [$user = new User()]; + $dto->futureUserSignature = $user; + + $workflow = $registry->get($entityWorkflow, 'dummy'); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']); + + yield [$entityWorkflow, 'to_post-signature', ['2eabe9e6-79c2-11ef-986c-2ba376180859'], [['signer' => spl_object_hash($user)]], 'A transition forward is blocked if a signature is pending']; + yield [$entityWorkflow, 'to_cancel', [], [], 'A transition backward is allowed, even if a signature is pending']; + + $registry = self::buildRegistry(); + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers = [$user = new User()]; + $dto->futureUserSignature = $user; + + $workflow = $registry->get($entityWorkflow, 'dummy'); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); + + yield [$entityWorkflow, 'to_post-signature', [], [], 'A transition forward is allowed when the signature is applyied']; + + $registry = self::buildRegistry(); + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers = [new User()]; + $dto->futurePersonSignatures = [new Person(), $p2 = new Person()]; + + $workflow = $registry->get($entityWorkflow, 'dummy'); + $workflow->apply($entityWorkflow, 'to_signature', ['context' => $dto, 'transitionAt' => new \DateTimeImmutable(), 'byUser' => new User(), 'transition' => 'to_signature']); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); + + yield [$entityWorkflow, 'to_post-signature', ['2eabe9e6-79c2-11ef-986c-2ba376180859'], [['signer' => spl_object_hash($p2)]], 'A transition forward is not allowed as a signature is still pending']; + yield [$entityWorkflow, 'to_cancel', [], [], 'A transition backward is allowed, even if a signature is pending']; + } + + private static function buildRegistry(?ChillEntityRenderManagerInterface $chillEntityRender = null, ?Security $security = null): Registry + { + $builder = new DefinitionBuilder(); + $builder + ->setInitialPlaces('initial') + ->addPlaces(['initial', 'signature', 'post-signature', 'cancel']) + ->addTransition(new Transition('to_signature', 'initial', 'signature')) + ->addTransition($postSignature = new Transition('to_post-signature', 'signature', 'post-signature')) + ->addTransition($cancel = new Transition('to_cancel', 'signature', 'cancel')) + ; + + $transitionsMetadata = new \SplObjectStorage(); + $transitionsMetadata->attach($postSignature, ['isForward' => true]); + $transitionsMetadata->attach($cancel, ['isForward' => false]); + + $metadata = new InMemoryMetadataStore( + placesMetadata: ['signature' => ['isSignature' => ['person', 'user']]], + transitionsMetadata: $transitionsMetadata, + ); + + $builder->setMetadataStore($metadata); + + if (null !== $chillEntityRender) { + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber(new EntityWorkflowGuardUnsignedTransition($chillEntityRender, $security)); + } + + $workflow = new Workflow( + $builder->build(), + new EntityWorkflowMarkingStore(), + $eventDispatcher ?? null, + 'dummy', + ); + + $registry = new Registry(); + $registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface { + public function supports(WorkflowInterface $workflow, object $subject): bool + { + return true; + } + }); + + return $registry; + } +} diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransition.php new file mode 100644 index 000000000..b26389c0f --- /dev/null +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/EntityWorkflowGuardUnsignedTransition.php @@ -0,0 +1,69 @@ + [ + ['guardWaitingForSignature', 0], + ], + ]; + } + + public function guardWaitingForSignature(GuardEvent $event): void + { + $entityWorkflow = $event->getSubject(); + + if (!$entityWorkflow instanceof EntityWorkflow) { + return; + } + + $transitionMetadata = $event->getWorkflow()->getMetadataStore()->getTransitionMetadata($event->getTransition()); + + if (false === ($transitionMetadata['isForward'] ?? true)) { + return; + } + + foreach ($entityWorkflow->getCurrentStep()->getSignatures() as $signature) { + if (EntityWorkflowSignatureStateEnum::PENDING === $signature->getState()) { + if ($this->security->isGranted(EntityWorkflowTransitionVoter::APPLY_ALL_TRANSITIONS, $entityWorkflow)) { + continue; + } + + $event->addTransitionBlocker( + new TransitionBlocker( + 'workflow.blocked_waiting_for_pending_signer', + '2eabe9e6-79c2-11ef-986c-2ba376180859', + ['signer' => $this->chillEntityRenderManager->renderString($signature->getSigner(), ['addAge' => false])] + ) + ); + } + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Security/CenterResolver/AccompanyingPeriodWorkEvaluationDocumentCenterResolver.php b/src/Bundle/ChillPersonBundle/Security/CenterResolver/AccompanyingPeriodWorkEvaluationDocumentCenterResolver.php new file mode 100644 index 000000000..973c771d1 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Security/CenterResolver/AccompanyingPeriodWorkEvaluationDocumentCenterResolver.php @@ -0,0 +1,40 @@ +centerResolverManager->resolveCenters($entity->getAccompanyingPeriodWorkEvaluation() + ->getAccompanyingPeriodWork()->getAccompanyingPeriod(), $options); + } + + public function supports($entity, ?array $options = []): bool + { + return $entity instanceof AccompanyingPeriodWorkEvaluationDocument; + } +}