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.
This commit is contained in:
Julien Fastré 2024-09-24 10:59:33 +02:00
parent 27df3b2c9b
commit cf2fe1bba7
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
4 changed files with 316 additions and 3 deletions

View File

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

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\EventSubscriber\EntityWorkflowGuardUnsignedTransition;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\TransitionBlocker;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowGuardUnsignedTransitionTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider guardWaitingForSignatureWithoutPermissionToApplyAllTransitionsProvider
*/
public function testGuardWaitingForSignatureWithoutPermissionToApplyAllTransitions(EntityWorkflow $entityWorkflow, string $transition, array $expectedErrors, array $expectedParameters, 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);
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;
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Security\Authorization\EntityWorkflowTransitionVoter;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
/**
* Block the transition on EntityWorkflow if there is a still pending signature.
*/
final readonly class EntityWorkflowGuardUnsignedTransition implements EventSubscriberInterface
{
public function __construct(private ChillEntityRenderManagerInterface $chillEntityRenderManager, private Security $security) {}
public static function getSubscribedEvents(): array
{
return [
'workflow.guard' => [
['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])]
)
);
}
}
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Security\CenterResolver;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
use Chill\MainBundle\Security\Resolver\ManagerAwareCenterResolverInterface;
use Chill\MainBundle\Security\Resolver\ManagerAwareCenterResolverTrait;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
final class AccompanyingPeriodWorkEvaluationDocumentCenterResolver implements CenterResolverInterface, ManagerAwareCenterResolverInterface
{
use ManagerAwareCenterResolverTrait;
public static function getDefaultPriority(): int
{
return 0;
}
public function resolveCenter($entity, ?array $options = []): Center|array
{
/* @var $entity AccompanyingPeriodWorkEvaluationDocument */
return $this->centerResolverManager->resolveCenters($entity->getAccompanyingPeriodWorkEvaluation()
->getAccompanyingPeriodWork()->getAccompanyingPeriod(), $options);
}
public function supports($entity, ?array $options = []): bool
{
return $entity instanceof AccompanyingPeriodWorkEvaluationDocument;
}
}