feat: soft-delete for teamer accounts

addresses #869dv9br3
This commit is contained in:
Björn Fromme
2026-08-11 12:26:13 +02:00
parent 388ecf9603
commit d654ce77fb
45 changed files with 1151 additions and 7 deletions
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260811075140 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer ADD deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE user ADD deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE teamer DROP deleted_at');
$this->addSql('ALTER TABLE user DROP deleted_at');
}
}
+13
View File
@@ -97,6 +97,13 @@ class UserDataHandler
/** @var User $user */ /** @var User $user */
$user = $users[0]; $user = $users[0];
// A deleted account still has to be matched here, otherwise the caller would take
// it for an unknown person and create a second account for them - resurrecting
// them under a new row. It is returned as found, without writing anything to it.
if (true === $user->isDeleted()) {
return $user;
}
$addressId = $profileResponse->getAddressId(); $addressId = $profileResponse->getAddressId();
$personId = $profileResponse->getPersonId(); $personId = $profileResponse->getPersonId();
if (null === $addressId || null === $personId) { if (null === $addressId || null === $personId) {
@@ -232,6 +239,12 @@ class UserDataHandler
*/ */
public function disableForRevokedCrmRoles(User $user): void public function disableForRevokedCrmRoles(User $user): void
{ {
// a deleted account is already excluded from everything and must not be written
// to by the CRM at all
if (true === $user->isDeleted()) {
return;
}
// an existing block may be a disciplinary one and must never be overwritten, but // an existing block may be a disciplinary one and must never be overwritten, but
// findLocalUser() may have refreshed the BusPro ids and nothing else flushes here // findLocalUser() may have refreshed the BusPro ids and nothing else flushes here
if (true === $user->isDisabled()) { if (true === $user->isDisabled()) {
@@ -0,0 +1,62 @@
<?php
namespace App\Controller\Admin\Teamer;
use App\Entity\Teamer;
use App\Htmx\HxRedirectResponse;
use App\Repository\ApplicationRepository;
use App\Repository\DispositionRepository;
use App\Service\Teamer\AccountDeletionHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteAccountController extends AbstractController
{
public function __construct(
private readonly AccountDeletionHandler $accountDeletionHandler,
private readonly ApplicationRepository $applicationRepository,
private readonly DispositionRepository $dispositionRepository,
) {
}
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
#[IsGranted('ROLE_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
$this->accountDeletionHandler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$this->addFlash('success', 'Der Account wurde gelöscht');
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
// open commitments survive the deletion and stay visible, but the teamer is no
// longer written to about them - withdrawing them is the admin's decision
return $this->render('admin/teamer/modal_delete_account.html.twig', [
'teamer' => $teamer,
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)),
'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)),
]);
}
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
#[IsGranted('ROLE_ADMIN')]
public function restore(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
$this->accountDeletionHandler->restore($teamer);
$this->addFlash('success', 'Der Account wurde wiederhergestellt');
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
}
return $this->render('admin/teamer/modal_restore_account.html.twig', [
'teamer' => $teamer,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Controller\Teamer\Profile;
use App\Entity\User;
use App\Form\DeleteAccountType;
use App\Htmx\HxRedirectResponse;
use App\Service\Teamer\AccountDeletionHandler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DeleteAccountController extends AbstractController
{
public function __construct(private readonly AccountDeletionHandler $accountDeletionHandler)
{
}
#[Route('/teamer/profile/delete-account', name: 'app_teamer_profile_delete_account')]
#[IsGranted('ROLE_TEAMER')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(DeleteAccountType::class, null, ['hx_post' => $request->getUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->accountDeletionHandler->delete($user, AccountDeletionHandler::SOURCE_SELF);
$this->addFlash('success', 'Dein Account wurde gelöscht');
// the session stays authenticated until the next request, so the logout is
// done here rather than left to the DeletedUserSubscriber
return new HxRedirectResponse($this->generateUrl('app_security_logout'));
}
return $this->render('teamer/profile/modal_delete_account.html.twig', [
'form' => $form->createView(),
]);
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ interface SoftDeletableEntityInterface
{ {
public function getDeletedAt(): ?\DateTimeImmutable; public function getDeletedAt(): ?\DateTimeImmutable;
public function setDeletedAt(\DateTimeImmutable $createdAt): static; public function setDeletedAt(?\DateTimeImmutable $deletedAt): static;
} }
+3 -1
View File
@@ -6,6 +6,7 @@ use App\BusProNet\Model\ProfileResponse;
use App\Entity\Embeddable\Address; use App\Entity\Embeddable\Address;
use App\Entity\Embeddable\BankAccount; use App\Entity\Embeddable\BankAccount;
use App\Entity\Embeddable\Communication; use App\Entity\Embeddable\Communication;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity; use App\Entity\Traits\TimestampableEntity;
use App\Repository\TeamerRepository; use App\Repository\TeamerRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -16,9 +17,10 @@ use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: TeamerRepository::class)] #[ORM\Entity(repositoryClass: TeamerRepository::class)]
class Teamer implements TimestampableEntityInterface class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterface
{ {
use TimestampableEntity; use TimestampableEntity;
use SoftDeletableEntity;
public const STATUS_NEW = 'new'; public const STATUS_NEW = 'new';
public const STATUS_EXISTING = 'existing'; public const STATUS_EXISTING = 'existing';
+8 -1
View File
@@ -14,7 +14,7 @@ trait SoftDeletableEntity
return $this->deletedAt; return $this->deletedAt;
} }
public function setDeletedAt(\DateTimeImmutable $deletedAt): static public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{ {
$this->deletedAt = $deletedAt; $this->deletedAt = $deletedAt;
@@ -28,6 +28,13 @@ trait SoftDeletableEntity
return $this; return $this;
} }
public function setRestored(): static
{
$this->setDeletedAt(null);
return $this;
}
public function isDeleted(): bool public function isDeleted(): bool
{ {
return null !== $this->getDeletedAt(); return null !== $this->getDeletedAt();
+19 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity; namespace App\Entity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity; use App\Entity\Traits\TimestampableEntity;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -12,9 +13,10 @@ use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, TimestampableEntityInterface class User implements UserInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
{ {
use TimestampableEntity; use TimestampableEntity;
use SoftDeletableEntity;
/** /**
* Assignable roles and their labels. * Assignable roles and their labels.
@@ -315,6 +317,11 @@ class User implements UserInterface, TimestampableEntityInterface
public function setTeamer(?Teamer $teamer): static public function setTeamer(?Teamer $teamer): static
{ {
// keep the inverse side in sync: it is only ever hydrated from the database, so
// without this a teamer attached during the request has no user to point back to
$this->teamer?->setUser(null);
$teamer?->setUser($this);
$this->teamer = $teamer; $this->teamer = $teamer;
return $this; return $this;
@@ -408,6 +415,17 @@ class User implements UserInterface, TimestampableEntityInterface
return $this->setDisabledAt(new \DateTimeImmutable()); return $this->setDisabledAt(new \DateTimeImmutable());
} }
/**
* Whether the account is barred from logging in, for either reason. A block and a
* deletion are independent states: a disciplinary block must survive a deletion and
* the restore that follows it, so the two are only ever combined for decisions,
* never merged into one another.
*/
public function isBlocked(): bool
{
return $this->isDeleted() || $this->isDisabled();
}
public function getDisabledReason(): ?string public function getDisabledReason(): ?string
{ {
return $this->disabledReason; return $this->disabledReason;
@@ -0,0 +1,54 @@
<?php
namespace App\EventListener;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Ends the session of a user whose account was deleted while they were logged in.
*
* The UserChecker only runs while authenticating, and Symfony's ContextListener does not
* re-run it when restoring a session from the cookie. Without this an account deleted by
* an admin would stay usable until the person logs out by themselves, which is exactly
* the continued participation a deletion is meant to end.
*/
class DeletedUserSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly Security $security,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
public static function getSubscribedEvents(): array
{
return [
// ahead of RequiredTeamerCheckSubscriber, so a deleted teamer is logged out
// rather than being sent into an outstanding check
KernelEvents::REQUEST => ['onKernelRequest', 10],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
if (false === $user instanceof User || false === $user->isDeleted()) {
return;
}
$this->security->logout(false);
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('app_security_login')));
}
}
@@ -4,6 +4,7 @@ namespace App\EventListener;
use App\Email\Mailer; use App\Email\Mailer;
use App\Entity\Application; use App\Entity\Application;
use App\Entity\Teamer;
use App\Entity\Upload; use App\Entity\Upload;
use App\Event\ApplicationStatusEvent; use App\Event\ApplicationStatusEvent;
use App\Event\AssignmentCalledOffEvent; use App\Event\AssignmentCalledOffEvent;
@@ -46,6 +47,13 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
{ {
$disposition = $event->getDisposition(); $disposition = $event->getDisposition();
$teamer = $disposition->getTeamer(); $teamer = $disposition->getTeamer();
// checked before the contract is rendered, so no document is produced for a mail
// that is never sent
if (true === $this->isExcluded($teamer)) {
return;
}
$assignment = $disposition->getAssignment(); $assignment = $disposition->getAssignment();
$destination = $assignment->getDestination(); $destination = $assignment->getDestination();
@@ -78,6 +86,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
$disposition = $document->getDisposition(); $disposition = $document->getDisposition();
$teamer = $disposition->getTeamer(); $teamer = $disposition->getTeamer();
if (true === $this->isExcluded($teamer)) {
return;
}
$assignment = $disposition->getAssignment(); $assignment = $disposition->getAssignment();
$destination = $assignment->getDestination(); $destination = $assignment->getDestination();
@@ -152,6 +165,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
$disposition = $document->getDisposition(); $disposition = $document->getDisposition();
$teamer = $disposition->getTeamer(); $teamer = $disposition->getTeamer();
if (true === $this->isExcluded($teamer)) {
return;
}
$documentTypeLabel = match ($document->getType()) { $documentTypeLabel = match ($document->getType()) {
Upload::TYPE_CONTRACT => 'dein Honorarvertrag', Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
Upload::TYPE_INVOICE => 'deine Honorarnote', Upload::TYPE_INVOICE => 'deine Honorarnote',
@@ -182,6 +199,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
$teamer = $application->getTeamer(); $teamer = $application->getTeamer();
if (true === $this->isExcluded($teamer)) {
return;
}
$this->mailer->createAndSendEmail([ $this->mailer->createAndSendEmail([
'application' => $application, 'application' => $application,
], [ ], [
@@ -203,6 +224,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
$disposition = $event->getDisposition(); $disposition = $event->getDisposition();
$teamer = $disposition->getTeamer(); $teamer = $disposition->getTeamer();
if (true === $this->isExcluded($teamer)) {
return;
}
$this->mailer->createAndSendEmail([ $this->mailer->createAndSendEmail([
'assignment' => $disposition->getAssignment(), 'assignment' => $disposition->getAssignment(),
'reason' => $disposition->getCalledOffReason(), 'reason' => $disposition->getCalledOffReason(),
@@ -222,6 +247,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
foreach ($assignment->getDispositions() as $disposition) { foreach ($assignment->getDispositions() as $disposition) {
$teamer = $disposition->getTeamer(); $teamer = $disposition->getTeamer();
if (true === $this->isExcluded($teamer)) {
continue;
}
$this->mailer->createAndSendEmail([ $this->mailer->createAndSendEmail([
'assignment' => $assignment, 'assignment' => $assignment,
], [ ], [
@@ -231,4 +260,13 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
]); ]);
} }
} }
/**
* A deleted teamer is excluded from all communication. Their dispositions and
* documents stay in place and remain visible, they are simply no longer written to.
*/
private function isExcluded(?Teamer $teamer): bool
{
return null === $teamer || true === $teamer->isDeleted();
}
} }
@@ -58,6 +58,9 @@ class RequiredTeamerCheckSubscriber implements EventSubscriberInterface
// while a check is outstanding - otherwise a teamer asked to confirm // while a check is outstanding - otherwise a teamer asked to confirm
// wrong data has no way to fix it first // wrong data has no way to fix it first
'app_teamer_profile_index', 'app_teamer_profile_index',
// deleting the own account must stay reachable while a check is outstanding,
// otherwise a teamer could be bounced into a check forever with no way out
'app_teamer_profile_delete_account',
'app_upload_delete', 'app_upload_delete',
'app_security_logout', 'app_security_logout',
], true)) { ], true)) {
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\IsTrue;
class DeleteAccountType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// deleting one's own account cannot be undone without asking the office, so it
// takes a deliberate second step rather than a single click
$builder->add('confirmed', CheckboxType::class, [
'label' => 'Ja, ich möchte meinen Account endgültig löschen',
'mapped' => false,
'constraints' => [
new IsTrue([
'message' => 'Bitte bestätige die Löschung',
]),
],
]);
}
}
+4
View File
@@ -59,6 +59,10 @@ class TeamerFilterType extends AbstractType
'label' => 'inaktive Teamer:innen anzeigen', 'label' => 'inaktive Teamer:innen anzeigen',
'required' => false, 'required' => false,
]) ])
->add('includeDeleted', CheckboxType::class, [
'label' => 'gelöschte Teamer:innen anzeigen',
'required' => false,
])
->add('apply', SubmitType::class, [ ->add('apply', SubmitType::class, [
'label' => 'filtern', 'label' => 'filtern',
]) ])
+13
View File
@@ -13,6 +13,7 @@ class TeamerFilterDto extends AbstractFilterDto
protected bool $driverLicenseVerified = false; protected bool $driverLicenseVerified = false;
protected bool $noTrainings = false; protected bool $noTrainings = false;
protected bool $includeInactive = false; protected bool $includeInactive = false;
protected bool $includeDeleted = false;
public function getName(): ?string public function getName(): ?string
{ {
@@ -119,4 +120,16 @@ class TeamerFilterDto extends AbstractFilterDto
return $this; return $this;
} }
public function isIncludeDeleted(): bool
{
return $this->includeDeleted;
}
public function setIncludeDeleted(bool $includeDeleted): static
{
$this->includeDeleted = $includeDeleted;
return $this;
}
} }
+10 -1
View File
@@ -41,7 +41,12 @@ class AvailabilityRepository extends ServiceEntityRepository
if (false === $custom) { if (false === $custom) {
$qb->andWhere($qb->expr()->isNull('availability.owner')); $qb->andWhere($qb->expr()->isNull('availability.owner'));
} else { } else {
$qb->andWhere($qb->expr()->isNotNull('availability.owner')); $qb
->andWhere($qb->expr()->isNotNull('availability.owner'))
// a deleted teamer takes no further assignments, so their own
// availabilities must no longer be offered for disposition
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
;
} }
return $qb->getQuery(); return $qb->getQuery();
@@ -49,6 +54,10 @@ class AvailabilityRepository extends ServiceEntityRepository
public function getSelectableForTeamer(Teamer $teamer): array public function getSelectableForTeamer(Teamer $teamer): array
{ {
if (true === $teamer->isDeleted()) {
return [];
}
$qb = $this->createQueryBuilder('availability'); $qb = $this->createQueryBuilder('availability');
return $qb return $qb
+9 -1
View File
@@ -106,7 +106,14 @@ class TeamerRepository extends ServiceEntityRepository
; ;
} }
if (false === $filterDto->isIncludeInactive()) { if (false === $filterDto->isIncludeDeleted()) {
$qb->andWhere($qb->expr()->isNull('teamer.deletedAt'));
}
// a deleted teamer never logs in again and would always count as inactive, so
// asking for them has to lift this filter - otherwise they could not be found
// again to be restored
if (false === $filterDto->isIncludeInactive() && false === $filterDto->isIncludeDeleted()) {
$qb $qb
->andWhere($qb->expr()->gte('user.lastLoginAt', ':lastLoginThreshold')) ->andWhere($qb->expr()->gte('user.lastLoginAt', ':lastLoginThreshold'))
->setParameter('lastLoginThreshold', new \DateTimeImmutable($this->teamerInactivePeriod)) ->setParameter('lastLoginThreshold', new \DateTimeImmutable($this->teamerInactivePeriod))
@@ -125,6 +132,7 @@ class TeamerRepository extends ServiceEntityRepository
$qb->expr()->like('teamer.lastName', ':search'), $qb->expr()->like('teamer.lastName', ':search'),
$qb->expr()->like('teamer.firstName', ':search'), $qb->expr()->like('teamer.firstName', ':search'),
)) ))
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
->orderBy('teamer.lastName', 'ASC') ->orderBy('teamer.lastName', 'ASC')
->addOrderBy('teamer.firstName', 'ASC') ->addOrderBy('teamer.firstName', 'ASC')
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%') ->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
+5
View File
@@ -52,6 +52,9 @@ class UserRepository extends ServiceEntityRepository
)); ));
return $qb return $qb
// andWhere after a chain of orWhere yields "(... OR ...) AND ...", so this
// narrows the whole list rather than widening it by one more alternative
->andWhere($qb->expr()->isNull('u.deletedAt'))
->orderBy('u.lastName', 'ASC') ->orderBy('u.lastName', 'ASC')
->getQuery() ->getQuery()
->getResult() ->getResult()
@@ -82,6 +85,7 @@ class UserRepository extends ServiceEntityRepository
return $qb return $qb
->andWhere('JSON_CONTAINS(user.roles, :role) = 1') ->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
->andWhere($qb->expr()->isNull('user.deletedAt'))
->setParameter('role', json_encode($role)) ->setParameter('role', json_encode($role))
->getQuery() ->getQuery()
->getResult() ->getResult()
@@ -98,6 +102,7 @@ class UserRepository extends ServiceEntityRepository
$qb->expr()->like('user.firstName', ':search'), $qb->expr()->like('user.firstName', ':search'),
)) ))
->andWhere('JSON_CONTAINS(user.roles, :role) = 1') ->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
->andWhere($qb->expr()->isNull('user.deletedAt'))
->orderBy('user.lastName', 'ASC') ->orderBy('user.lastName', 'ASC')
->addOrderBy('user.firstName', 'ASC') ->addOrderBy('user.firstName', 'ASC')
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%') ->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
+13
View File
@@ -140,6 +140,19 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->findLocalUser($profileResponse) ->findLocalUser($profileResponse)
; ;
// A deleted account is excluded from every process, and the CRM must not be able
// to undo that: no data is written back, no role is granted or revoked, not even
// lastLoginAt is bumped. It is returned untouched so the UserChecker can refuse
// the login and say why. Only an admin restores it.
if (true === $user?->isDeleted()) {
$this->logger->info('Skip CRM sync for deleted user', [
'user_id' => $user->getId(),
'user_email' => $email,
]);
return $user;
}
// BusPro knows this person but grants them nothing in this application, so they // BusPro knows this person but grants them nothing in this application, so they
// are no user of it: never create an account, block an existing one. Returning // are no user of it: never create an account, block an existing one. Returning
// the blocked user lets the UserChecker explain why the login was refused. // the blocked user lets the UserChecker explain why the login was refused.
+6
View File
@@ -15,6 +15,12 @@ class UserChecker implements UserCheckerInterface
return; return;
} }
// checked before the block, as a deletion is the stronger statement and its
// message has to win when an account carries both
if (true === $user->isDeleted()) {
throw new CustomUserMessageAccountStatusException('Dein Account wurde gelöscht. Wende dich an das Team, wenn du ihn wiederherstellen möchtest.');
}
if (true === $user->isDisabled()) { if (true === $user->isDisabled()) {
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason()); throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
} }
@@ -44,6 +44,12 @@ class ImpersonationVoter extends Voter
return false; return false;
} }
// a deleted account is excluded from every process, so impersonating it must not
// become a way back into the teamer area
if (true === $targetUser->isDeleted()) {
return false;
}
// Admin is the only role allowed to impersonate // Admin is the only role allowed to impersonate
if (false === $this->security->isGranted('ROLE_ADMIN')) { if (false === $this->security->isGranted('ROLE_ADMIN')) {
return false; return false;
@@ -33,6 +33,10 @@ class TeamerFilterHandler extends AbstractFilterHandler
$filterDto->setIncludeInactive((bool) $data['include_inactive']); $filterDto->setIncludeInactive((bool) $data['include_inactive']);
} }
if (isset($data['include_deleted'])) {
$filterDto->setIncludeDeleted((bool) $data['include_deleted']);
}
if (0 < count($data['job_profiles'])) { if (0 < count($data['job_profiles'])) {
$jobProfiles = $this $jobProfiles = $this
->entityManager ->entityManager
@@ -57,6 +61,7 @@ class TeamerFilterHandler extends AbstractFilterHandler
'driver_license_verified' => $filterDto->hasDriverLicenseVerified(), 'driver_license_verified' => $filterDto->hasDriverLicenseVerified(),
'no_trainings' => $filterDto->hasNoTrainings(), 'no_trainings' => $filterDto->hasNoTrainings(),
'include_inactive' => $filterDto->isIncludeInactive(), 'include_inactive' => $filterDto->isIncludeInactive(),
'include_deleted' => $filterDto->isIncludeDeleted(),
'job_profiles' => array_map(function (JobProfile $jobProfile) { 'job_profiles' => array_map(function (JobProfile $jobProfile) {
return $jobProfile->getId(); return $jobProfile->getId();
}, $filterDto->getJobProfiles()), }, $filterDto->getJobProfiles()),
@@ -30,7 +30,9 @@ class DispositionReminderService
->innerJoin('disposition.assignment', 'assignment') ->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->notIn('assignment.status', ':statusAssignment'), $qb->expr()->notIn('assignment.status', ':statusAssignment'),
$qb->expr()->eq('destination.dateFrom', ':dateFrom'), $qb->expr()->eq('destination.dateFrom', ':dateFrom'),
$qb->expr()->eq('document.status', ':statusContract'), $qb->expr()->eq('document.status', ':statusContract'),
@@ -35,8 +35,12 @@ class UploadReminderService
->innerJoin('disposition.assignment', 'assignment') ->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->isNull('document'), $qb->expr()->isNull('document'),
// deleted teamers are excluded here rather than when sending, so that the
// count reported below stays truthful
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'), $qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'), $qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
@@ -90,8 +94,10 @@ class UploadReminderService
->innerJoin('disposition.assignment', 'assignment') ->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType') ->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->isNull('document'), $qb->expr()->isNull('document'),
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX( $qb->expr()->orX(
// Due date can be determined by assignment's date which potentially overrides destination date // Due date can be determined by assignment's date which potentially overrides destination date
$qb->expr()->andX( $qb->expr()->andX(
@@ -0,0 +1,98 @@
<?php
namespace App\Service\Teamer;
use App\Entity\Teamer;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* Soft-deletes a teamer together with their user account, and restores them again.
*
* A deletion excludes the person from every forward-looking process - the teamer list,
* autocompletes, forms, mailings and cron reminders - while every existing record stays
* untouched and visible: dispositions, applications, documents, feedback and the
* contracts and invoices rendered from them are business records that have to survive.
*
* Both Teamer and User carry their own flag, because neither side is guaranteed to
* exist and the queries that have to filter only ever have one of the two aliases at
* hand. This class is the only place that writes either flag, so the two cannot drift
* apart.
*
* Deletion is deliberately independent of the block expressed by User::$disabledAt: a
* disciplinary block has to survive a deletion and the restore that follows it.
*/
class AccountDeletionHandler
{
public const SOURCE_ADMIN = 'admin';
public const SOURCE_SELF = 'self';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
public function delete(Teamer|User $subject, string $source): void
{
[$teamer, $user] = $this->resolvePair($subject);
if (false === $this->isDeleted($teamer, $user)) {
$teamer?->setDeleted();
$user?->setDeleted();
$this->entityManager->flush();
$this->logger->info('Delete account', [...$this->logContext($teamer, $user), 'source' => $source]);
}
}
public function restore(Teamer|User $subject): void
{
[$teamer, $user] = $this->resolvePair($subject);
if (true === $this->isDeleted($teamer, $user)) {
$teamer?->setRestored();
$user?->setRestored();
$this->entityManager->flush();
$this->logger->info('Restore account', $this->logContext($teamer, $user));
}
}
/**
* @return array{0: ?Teamer, 1: ?User}
*/
private function resolvePair(Teamer|User $subject): array
{
if ($subject instanceof Teamer) {
return [$subject, $subject->getUser()];
}
return [$subject->getTeamer(), $subject];
}
/**
* Either side being flagged counts as deleted, so that a pair left inconsistent by
* an earlier failure is repaired rather than skipped.
*/
private function isDeleted(?Teamer $teamer, ?User $user): bool
{
return true === $teamer?->isDeleted() || true === $user?->isDeleted();
}
/**
* @return array<string, mixed>
*/
private function logContext(?Teamer $teamer, ?User $user): array
{
return [
'teamer' => $teamer?->getFullName(),
'teamer_id' => $teamer?->getId(),
'user' => $user?->getEmail(),
'user_id' => $user?->getId(),
];
}
}
@@ -9,6 +9,8 @@ class Application extends Constraint
{ {
public string $message = 'Diese Bewerbung überlappt sich mit einem deiner bestätigten Einsätze'; public string $message = 'Diese Bewerbung überlappt sich mit einem deiner bestätigten Einsätze';
public string $deletedMessage = 'Für einen gelöschten Account können keine Bewerbungen angelegt werden';
public function getTargets(): array|string public function getTargets(): array|string
{ {
return static::CLASS_CONSTRAINT; return static::CLASS_CONSTRAINT;
@@ -21,6 +21,18 @@ class ApplicationValidator extends ConstraintValidator
$teamer = $application->getTeamer(); $teamer = $application->getTeamer();
// a deleted teamer is excluded from every process, so no new application may be
// created for them - not even by an admin acting on their behalf
if (true === $teamer->isDeleted()) {
$this
->context
->buildViolation($constraint->deletedMessage)
->addViolation()
;
return;
}
if (true === $teamer->isAllowOverlappingApplications()) { if (true === $teamer->isAllowOverlappingApplications()) {
return; return;
} }
@@ -1,5 +1,6 @@
{% set assignment = disposition.assignment %} {% set assignment = disposition.assignment %}
<span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span> <span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span>
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
@@ -0,0 +1,6 @@
{% if teamer is not null and teamer.deleted %}
<span class="inline-block py-1 px-2 text-xs bg-gray-500 text-white"
title="Account gelöscht am {{ teamer.deletedAt | date('d.m.Y') }}">
gelöscht
</span>
{% endif %}
@@ -100,6 +100,7 @@
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"> <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
{{ application.teamer }} {{ application.teamer }}
</a> </a>
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
</td> </td>
<td> <td>
<div class="flex items-center justify-end space-x-1" <div class="flex items-center justify-end space-x-1"
@@ -0,0 +1,33 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Account löschen{% endblock %}
{% block content %}
<div class="pb-4">
Möchtest du <em>{{ teamer }}</em> und den zugehörigen Benutzeraccount wirklich löschen?
</div>
<div class="pb-4">
Die Person wird aus allen Listen, Formularen und dem Mailversand ausgeschlossen und kann
sich nicht mehr anmelden. Bestehende Einsätze, Dokumente und Feedbacks bleiben erhalten
und weiterhin sichtbar.
</div>
{% if pendingApplicationCount > 0 or upcomingDispositionCount > 0 %}
<div class="p-4 bg-red-50 border border-red-200 rounded-md">
<div class="font-bold pb-2">Achtung: offene Vorgänge</div>
<ul class="list-disc list-inside">
{% if pendingApplicationCount > 0 %}
<li>{{ pendingApplicationCount }} offene Bewerbung(en)</li>
{% endif %}
{% if upcomingDispositionCount > 0 %}
<li>{{ upcomingDispositionCount }} anstehende(r) Einsatz/Einsätze</li>
{% endif %}
</ul>
<div class="pt-2">
Diese bleiben bestehen, die Person wird darüber aber nicht mehr benachrichtigt.
Sage sie bei Bedarf vorher ab.
</div>
</div>
{% endif %}
{% endblock %}
{% block button_confirm %}Löschen{% endblock %}
@@ -0,0 +1,17 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Account wiederherstellen{% endblock %}
{% block content %}
<div class="pb-4">
Möchtest du <em>{{ teamer }}</em> und den zugehörigen Benutzeraccount wirklich
wiederherstellen?
</div>
<div>
Die Person erscheint danach wieder in allen Listen und erhält wieder E-Mails. Liegt in
BusPro keine Berechtigung mehr vor, wird der Account bei der nächsten Anmeldung erneut
gesperrt.
</div>
{% endblock %}
{% block button_confirm %}Wiederherstellen{% endblock %}
@@ -48,6 +48,7 @@
hx-target="body" hx-target="body"
hx-swap="beforeend"> hx-swap="beforeend">
<span>{{ application.teamer }}</span> <span>{{ application.teamer }}</span>
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
{{ icon('info', 'w-4 h-4') }} {{ icon('info', 'w-4 h-4') }}
</button> </button>
<twig:RatingStars rating="{{ application.teamer.averageRating }}" /> <twig:RatingStars rating="{{ application.teamer.averageRating }}" />
@@ -155,6 +156,7 @@
hx-target="body" hx-target="body"
hx-swap="beforeend"> hx-swap="beforeend">
<span>{{ disposition.teamer }}</span> <span>{{ disposition.teamer }}</span>
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
{{ icon('info', 'w-4 h-4') }} {{ icon('info', 'w-4 h-4') }}
</button> </button>
<twig:RatingStars rating="{{ disposition.teamer.averageRating }}" /> <twig:RatingStars rating="{{ disposition.teamer.averageRating }}" />
@@ -83,6 +83,7 @@
</td> </td>
<td> <td>
{{ disposition.teamer }} {{ disposition.teamer }}
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
</td> </td>
<td> <td>
{{ upload.status|upload_status_badge }} {{ upload.status|upload_status_badge }}
@@ -60,6 +60,7 @@
</td> </td>
<td> <td>
{{ feedback.teamer.firstName }} {{ feedback.teamer.firstName }}
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': feedback.teamer } %}
</td> </td>
<td> <td>
{% if feedback.assignmentDateFrom and feedback.assignmentDateTo %} {% if feedback.assignmentDateFrom and feedback.assignmentDateTo %}
@@ -104,7 +104,19 @@
{{ icon('check') }} {{ icon('check') }}
</button> </button>
{% endif %} {% endif %}
{% if teamer.user is not null and teamer.user.disabled %} {% if teamer.deleted %}
{% if is_granted('ROLE_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_restore_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account wiederherstellen">
{{ icon('refresh') }}
</button>
{% endif %}
{% elseif teamer.user is not null and teamer.user.disabled %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
<button type="button" <button type="button"
role="menuitem" role="menuitem"
@@ -132,11 +144,28 @@
{{ icon('mask') }} {{ icon('mask') }}
</a> </a>
{% endif %} {% endif %}
{% if is_granted('ROLE_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
tabindex="-1"
hx-get="{{ path('app_admin_teamer_delete_account', { 'uuid': teamer.uuid }) }}"
hx-target="body"
hx-swap="beforeend"
title="Account löschen">
{{ icon('delete') }}
</button>
{% endif %}
{% endif %} {% endif %}
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}"> <a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
{{ icon('user') }} {{ icon('user') }}
</a> </a>
</div> </div>
{% if teamer.deleted %}
<div class="py-1 px-2 mt-2 text-xs bg-gray-500 text-white">
gel. am {{ teamer.deletedAt | date('d.m.Y') }}
</div>
{% endif %}
{% if teamer.user is not null and teamer.user.disabled %} {% if teamer.user is not null and teamer.user.disabled %}
<div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white"> <div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white">
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }} gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
@@ -14,6 +14,7 @@
{{ form_row(filterForm.driverLicenseVerified) }} {{ form_row(filterForm.driverLicenseVerified) }}
{{ form_row(filterForm.noTrainings) }} {{ form_row(filterForm.noTrainings) }}
{{ form_row(filterForm.includeInactive) }} {{ form_row(filterForm.includeInactive) }}
{{ form_row(filterForm.includeDeleted) }}
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }} {{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
+17
View File
@@ -164,6 +164,23 @@
</button> </button>
</div> </div>
</div> </div>
<div class="pt-8 mt-4 border-t border-gray-200">
<h4 class="font-bold pb-2">
Account löschen
</h4>
<div class="pb-2 text-sm">
Du kannst deinen Account löschen. Du wirst danach aus allen Listen
und dem E-Mail-Versand ausgeschlossen und kannst dich nicht mehr
anmelden.
</div>
<button type="button"
class="btn btn--small bg-red-500 text-white"
hx-get="{{ path('app_teamer_profile_delete_account') }}"
hx-target="body"
hx-swap="beforeend">
Account löschen
</button>
</div>
</div> </div>
</div> </div>
@@ -0,0 +1,27 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Account löschen{% endblock %}
{% block content %}
{{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-4">
<div>
Wenn du deinen Account löschst, wirst du aus allen Listen, Formularen und dem
E-Mail-Versand ausgeschlossen und kannst dich nicht mehr anmelden.
</div>
<div>
Deine bisherigen Einsätze, Honorarverträge und Honorarnoten bleiben aus rechtlichen
Gründen gespeichert und für das Team weiterhin sichtbar.
</div>
<div>
Wenn du deinen Account später wieder brauchst, wende dich bitte an das Büro. Du
kannst ihn nicht selbst wiederherstellen.
</div>
{{ form_row(form.confirmed) }}
</div>
<button type="submit" class="btn bg-red-500 text-white">
Account löschen
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+67
View File
@@ -466,6 +466,73 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(2, $user->getBusProPersonId()); $this->assertSame(2, $user->getBusProPersonId());
} }
/**
* A deleted account still has to be matched by email, otherwise the caller takes the
* person for unknown and creates a second account for them - which would resurrect
* them under a new row and defeat the deletion entirely. Nothing is written to it.
*/
public function testFindLocalUserReturnsADeletedEmailMatchWithoutWritingToIt(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
;
$user->setDeleted();
$profileResponse = $this->createProfileResponse();
$repository = $this->createMock(ObjectRepository::class);
$repository
->expects($this->once())
->method('findOneBy')
->willReturn(null);
$repository
->expects($this->once())
->method('findBy')
->with([
'email' => '[email protected]',
])
->willReturn([$user]);
$this->entityManager
->expects($this->once())
->method('getRepository')
->with(User::class)
->willReturn($repository);
$handler = new UserDataHandler($this->entityManager, $this->logger);
$resolvedUser = $handler->findLocalUser($profileResponse);
$this->assertSame($user, $resolvedUser);
$this->assertSame(1, $user->getBusProAddressId());
$this->assertSame(2, $user->getBusProPersonId());
}
public function testDisableForRevokedCrmRolesLeavesADeletedAccountAlone(): void
{
$user = (new User())
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_TEAMER'])
;
$user->setDeleted();
$this->entityManager
->expects($this->never())
->method('flush');
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->disableForRevokedCrmRoles($user);
$this->assertFalse($user->isDisabled());
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
}
private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse
{ {
$address = (new BusProAddress()) $address = (new BusProAddress())
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity\Traits;
use App\Entity\SoftDeletableEntityInterface;
use App\Entity\Traits\SoftDeletableEntity;
use PHPUnit\Framework\TestCase;
class SoftDeletableEntityTest extends TestCase
{
public function testAFreshEntityIsNotDeleted(): void
{
$entity = $this->createEntity();
$this->assertFalse($entity->isDeleted());
$this->assertNull($entity->getDeletedAt());
}
public function testSetDeletedStampsTheTimestamp(): void
{
$entity = $this->createEntity();
$entity->setDeleted();
$this->assertTrue($entity->isDeleted());
$this->assertNotNull($entity->getDeletedAt());
}
public function testSetRestoredClearsTheTimestamp(): void
{
$entity = $this->createEntity();
$entity->setDeleted();
$entity->setRestored();
$this->assertFalse($entity->isDeleted());
$this->assertNull($entity->getDeletedAt());
}
public function testDeletedAtAcceptsNullSoDeletionsCanBeUndone(): void
{
$entity = $this->createEntity();
$entity->setDeletedAt(new \DateTimeImmutable());
$entity->setDeletedAt(null);
$this->assertFalse($entity->isDeleted());
}
private function createEntity(): SoftDeletableEntityInterface
{
return new class implements SoftDeletableEntityInterface {
use SoftDeletableEntity;
};
}
}
+39
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Entity; namespace App\Tests\Entity;
use App\Entity\Teamer;
use App\Entity\User; use App\Entity\User;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\ConstraintViolationListInterface;
@@ -118,6 +119,44 @@ class UserTest extends TestCase
$this->assertNull($enabled->getDisabledAt()); $this->assertNull($enabled->getDisabledAt());
} }
/**
* A block and a deletion are independent states: setting one must never imply or
* clear the other, so that a disciplinary block survives a deletion and the restore
* that follows it.
*/
public function testDeletionAndBlockAreIndependentStates(): void
{
$user = new User();
$this->assertFalse($user->isBlocked());
$user->setDeleted();
$this->assertTrue($user->isBlocked());
$this->assertFalse($user->isDisabled());
$user->setDisabled(true);
$disabledAt = $user->getDisabledAt();
$user->setRestored();
$this->assertFalse($user->isDeleted());
$this->assertTrue($user->isBlocked());
$this->assertSame($disabledAt, $user->getDisabledAt());
$user->setDisabled(false);
$this->assertFalse($user->isBlocked());
}
public function testSettingTheTeamerKeepsTheInverseSideInSync(): void
{
$teamer = new Teamer();
$user = (new User())->setTeamer($teamer);
$this->assertSame($user, $teamer->getUser());
$user->setTeamer(null);
$this->assertNull($teamer->getUser());
}
private function validate(User $user): ConstraintViolationListInterface private function validate(User $user): ConstraintViolationListInterface
{ {
return Validation::createValidatorBuilder() return Validation::createValidatorBuilder()
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Email\Mailer;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Event\ApplicationStatusEvent;
use App\Event\AssignmentCalledOffEvent;
use App\Event\DispositionCalledOffEvent;
use App\EventListener\EmailNotificationSubscriber;
use App\Model\ApplicationStatusDto;
use App\Repository\UserRepository;
use App\Service\Pdf\ContractRenderer;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* A deleted teamer must never be mailed again - this is the point of the deletion, so
* every handler that writes to a teamer is covered here.
*/
class EmailNotificationSubscriberTest extends TestCase
{
private Mailer&MockObject $mailer;
private EmailNotificationSubscriber $subscriber;
protected function setUp(): void
{
$this->mailer = $this->createMock(Mailer::class);
$this->subscriber = new EmailNotificationSubscriber(
$this->mailer,
$this->createMock(UserRepository::class),
$this->createMock(ContractRenderer::class),
);
}
public function testDeletedTeamerIsNotNotifiedAboutCalledOffDisposition(): void
{
$disposition = $this->createDisposition($this->createDeletedTeamer());
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
}
public function testActiveTeamerIsNotifiedAboutCalledOffDisposition(): void
{
$disposition = $this->createDisposition($this->createTeamer());
$this->mailer->expects($this->once())->method('createAndSendEmail');
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
}
public function testDeletedTeamerIsNotNotifiedAboutRejectedApplication(): void
{
$application = new Application(new Assignment(), $this->createDeletedTeamer());
$application->setStatus(Application::STATUS_REJECTED);
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->subscriber->onApplicationStatus(
new ApplicationStatusEvent(new ApplicationStatusDto($application))
);
}
/**
* The call-off of a whole assignment mails every teamer on it, so the exclusion has
* to happen per teamer rather than for the event as a whole.
*/
public function testCalledOffAssignmentSkipsOnlyTheDeletedTeamer(): void
{
$assignment = new Assignment();
$assignment->addDisposition($this->createDisposition($this->createTeamer(), $assignment));
$assignment->addDisposition($this->createDisposition($this->createDeletedTeamer(), $assignment));
$this->mailer->expects($this->once())->method('createAndSendEmail');
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
}
private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition
{
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
}
private function createTeamer(): Teamer
{
return (new Teamer())->setCommunication(
(new Communication())->setEmail('[email protected]')
);
}
private function createDeletedTeamer(): Teamer
{
$teamer = $this->createTeamer();
$teamer->setDeleted();
return $teamer;
}
}
+38
View File
@@ -86,6 +86,44 @@ class BpnAuthenticatorTest extends TestCase
$this->loadUser(); $this->loadUser();
} }
/**
* The CRM must not be able to undo a deletion, in either direction: neither by
* refreshing the account's data nor by blocking it further.
*/
public function testDeletedUserIsReturnedWithoutAnyCrmSync(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDeleted();
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn(['ROLE_TEAMER']);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
$this->userDataHandler->expects($this->never())->method('createLocalUser');
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
// returned rather than refused, so the UserChecker can explain the deletion
$this->assertSame($user, $this->loadUser());
}
public function testDeletedUserIsNotBlockedWhenTheCrmRevokedEveryRole(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDeleted();
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->assertSame($user, $this->loadUser());
$this->assertFalse($user->isDisabled());
}
private function createCrmAttributes(): CrmAttributesResponse private function createCrmAttributes(): CrmAttributesResponse
{ {
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]); return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Entity\User;
use App\Security\UserChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
class UserCheckerTest extends TestCase
{
private UserChecker $userChecker;
protected function setUp(): void
{
$this->userChecker = new UserChecker();
}
public function testDeletedUserIsRefused(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDeleted();
$this->expectException(CustomUserMessageAccountStatusException::class);
$this->expectExceptionMessageMatches('/gelöscht/');
$this->userChecker->checkPreAuth($user);
}
/**
* A deletion is the stronger statement, so its message has to win over the block
* message when an account carries both.
*/
public function testDeletedAndDisabledUserIsRefusedWithTheDeletionMessage(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDisabled(true);
$user->setDisabledReason('Disziplinarisch gesperrt');
$user->setDeleted();
$this->expectException(CustomUserMessageAccountStatusException::class);
$this->expectExceptionMessageMatches('/gelöscht/');
$this->userChecker->checkPreAuth($user);
}
public function testDisabledUserStillGetsTheBlockMessage(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDisabled(true);
$user->setDisabledReason('Disziplinarisch gesperrt');
$this->expectException(CustomUserMessageAccountStatusException::class);
$this->expectExceptionMessage('Dein Account wurde gesperrt: Disziplinarisch gesperrt');
$this->userChecker->checkPreAuth($user);
}
public function testActiveUserWithValidRolePasses(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$this->userChecker->checkPreAuth($user);
$this->assertFalse($user->isBlocked());
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Teamer;
use App\Entity\Teamer;
use App\Entity\User;
use App\Service\Teamer\AccountDeletionHandler;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class AccountDeletionHandlerTest extends TestCase
{
private EntityManagerInterface&MockObject $entityManager;
private AccountDeletionHandler $handler;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->handler = new AccountDeletionHandler(
$this->entityManager,
$this->createMock(LoggerInterface::class),
);
}
public function testDeleteFromTeamerSideFlagsBothEntities(): void
{
$teamer = new Teamer();
$user = (new User())->setTeamer($teamer);
$this->entityManager->expects($this->once())->method('flush');
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$this->assertTrue($teamer->isDeleted());
$this->assertTrue($user->isDeleted());
}
public function testDeleteFromUserSideFlagsBothEntities(): void
{
$teamer = new Teamer();
$user = (new User())->setTeamer($teamer);
$this->handler->delete($user, AccountDeletionHandler::SOURCE_SELF);
$this->assertTrue($teamer->isDeleted());
$this->assertTrue($user->isDeleted());
}
public function testDeleteWorksForUserWithoutTeamer(): void
{
$user = new User();
$this->entityManager->expects($this->once())->method('flush');
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
$this->assertTrue($user->isDeleted());
}
public function testDeleteWorksForTeamerWithoutUser(): void
{
$teamer = new Teamer();
$this->entityManager->expects($this->once())->method('flush');
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$this->assertTrue($teamer->isDeleted());
}
public function testDeletingTwiceIsANoOpAndKeepsTheOriginalTimestamp(): void
{
$teamer = new Teamer();
$user = (new User())->setTeamer($teamer);
$this->entityManager->expects($this->once())->method('flush');
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$deletedAt = $teamer->getDeletedAt();
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$this->assertSame($deletedAt, $teamer->getDeletedAt());
}
/**
* A deletion is not a block: the two states are independent, so that a disciplinary
* block survives a deletion and the restore that follows it.
*/
public function testDeleteLeavesRolesAndTheBlockUntouched(): void
{
$teamer = new Teamer();
$user = (new User())
->setTeamer($teamer)
->setRoles(['ROLE_TEAMER', 'ROLE_ADMIN'])
;
$user->setDisabled(true);
$disabledAt = $user->getDisabledAt();
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame($disabledAt, $user->getDisabledAt());
}
public function testRestoreClearsBothFlags(): void
{
$teamer = new Teamer();
$user = (new User())->setTeamer($teamer);
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
$this->handler->restore($teamer);
$this->assertFalse($teamer->isDeleted());
$this->assertFalse($user->isDeleted());
}
public function testRestoreOfAnActiveAccountIsANoOp(): void
{
$teamer = new Teamer();
$this->entityManager->expects($this->never())->method('flush');
$this->handler->restore($teamer);
$this->assertFalse($teamer->isDeleted());
}
/**
* Should a pair ever be left half-flagged, restoring has to repair it rather than
* skip it because one side already looks active.
*/
public function testRestoreRepairsAnInconsistentPair(): void
{
$teamer = (new Teamer())->setDeleted();
$user = (new User())->setTeamer($teamer);
$this->handler->restore($user);
$this->assertFalse($teamer->isDeleted());
$this->assertFalse($user->isDeleted());
}
}