feat: email the administrators when a role nomination appears

This commit is contained in:
2026-09-13 12:50:01 +02:00
parent fab89dead6
commit b41820c39d
12 changed files with 458 additions and 36 deletions
@@ -8,6 +8,7 @@ use App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\Role;
use App\Service\RoleApprovalUrlGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -76,6 +77,10 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
},
);
return new PendingRoleApprovalsWidgetProvider($repository, $urlGenerator);
return new PendingRoleApprovalsWidgetProvider(
$repository,
$urlGenerator,
new RoleApprovalUrlGenerator($urlGenerator),
);
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Tests\MessageHandler;
use App\Email\Mailer;
use App\Entity\User;
use App\Message\RoleNominationMessage;
use App\MessageHandler\RoleNominationHandler;
use App\Repository\UserRepository;
use App\Security\Role;
use App\Service\RoleApprovalUrlGenerator;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* The notification goes to the people who can act on it, and to nobody else: an account merely
* nominated for ROLE_ADMIN must not be told about other people's nominations.
*/
class RoleNominationHandlerTest extends TestCase
{
/** @var array<string, mixed>|null */
private ?array $sentOptions = null;
/** @var array<string, mixed>|null */
private ?array $sentContext = null;
private ?string $generatedRoute = null;
private ?int $generatedReferenceType = null;
public function testMailsEveryAdministrator(): void
{
$handler = $this->handler(
$this->user(7, '[email protected]'),
[
$this->user(1, '[email protected]', [Role::ADMIN]),
$this->user(2, '[email protected]', [Role::ADMIN]),
],
);
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
self::assertSame(['[email protected]', '[email protected]'], $this->sentOptions['to']);
self::assertSame('email/role_nomination.html.twig', $this->sentOptions['template']);
// Labelled for a human, not the raw role string.
self::assertSame(['Administration'], $this->sentContext['roles']);
// The filtered user list, not app_admin_user_permissions: that route renders a bare htmx
// fragment, which a mail client following the link would show unstyled.
self::assertSame('app_admin_user', $this->generatedRoute);
// Absolute, because a worker has no request to borrow a host from.
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $this->generatedReferenceType);
self::assertSame('https://my.ep-reisen.de/generated', $this->sentContext['approvalUrl']);
}
public function testAMissingAccountIsANoOp(): void
{
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]);
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
self::assertNull($this->sentOptions);
}
public function testWithoutAnAdministratorNothingIsSent(): void
{
$handler = $this->handler($this->user(7, '[email protected]'), []);
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
self::assertNull($this->sentOptions);
}
/**
* @param string[] $roles
*/
private function user(int $id, string $email, array $roles = []): User
{
$user = (new User($email))->setRoles($roles);
// The id is generated by Doctrine and has no setter, but the notification links to it.
$property = new \ReflectionProperty(User::class, 'id');
$property->setValue($user, $id);
return $user;
}
/**
* @param User[] $administrators
*/
private function handler(?User $nominee, array $administrators): RoleNominationHandler
{
$userRepository = $this->createStub(UserRepository::class);
$userRepository->method('find')->willReturn($nominee);
$userRepository->method('findAdministrators')->willReturn($administrators);
$mailer = $this->createStub(Mailer::class);
$mailer
->method('createAndSendEmail')
->willReturnCallback(function (array $context, array $options): void {
$this->sentContext = $context;
$this->sentOptions = $options;
})
;
// Records what was asked of the router rather than imitating its output: what matters is
// which route the mail points at and that it is absolute, not how a query string is escaped.
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
$urlGenerator
->method('generate')
->willReturnCallback(function (string $route, array $parameters, int $referenceType): string {
$this->generatedRoute = $route;
$this->generatedReferenceType = $referenceType;
return 'https://my.ep-reisen.de/generated';
})
;
return new RoleNominationHandler(
$userRepository,
$mailer,
new RoleApprovalUrlGenerator($urlGenerator),
$this->createStub(LoggerInterface::class),
);
}
}
+70
View File
@@ -9,6 +9,7 @@ use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Message\RoleNominationMessage;
use App\Security\BpnAuthenticator;
use App\Security\Crypt;
use App\Security\EmployeeDomainMatcher;
@@ -19,6 +20,8 @@ use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
@@ -28,6 +31,14 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
*/
class BpnAuthenticatorTest extends TestCase
{
/** @var object[] messages the authenticator dispatched during the login under test */
private array $dispatched = [];
protected function setUp(): void
{
$this->dispatched = [];
}
public function testNewAccountIsSeededFromTheCrm(): void
{
$persisted = null;
@@ -229,6 +240,54 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
public function testANewNominationIsAnnouncedOnce(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
$user = $this->loadUser($authenticator);
self::assertCount(1, $this->dispatched);
$message = $this->dispatched[0];
self::assertInstanceOf(RoleNominationMessage::class, $message);
// The role itself, not its marker: the marker is an internal bookkeeping detail.
self::assertSame([Role::ADMIN], $message->roles);
self::assertContains(Role::pending(Role::ADMIN), $user->getRoles());
}
public function testAStandingNominationIsNotAnnouncedAgain(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
// The nomination has not changed, so there is nothing new to tell an administrator about.
self::assertSame([], $this->dispatched);
}
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
self::assertSame([], $this->dispatched);
}
public function testALoginWithoutANominationAnnouncesNothing(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::TEAMER], []), null, $persisted);
$this->loadUser($authenticator);
self::assertSame([], $this->dispatched);
}
private function authenticator(
CrmAttributes $crmAttributes,
?User $existing,
@@ -264,6 +323,16 @@ class BpnAuthenticatorTest extends TestCase
$completenessChecker = $this->createStub(ProfileCompletenessChecker::class);
$completenessChecker->method('isComplete')->willReturn(true);
$messageBus = $this->createStub(MessageBusInterface::class);
$messageBus
->method('dispatch')
->willReturnCallback(function (object $message): Envelope {
$this->dispatched[] = $message;
return new Envelope($message);
})
;
return new BpnAuthenticator(
$this->createStub(UrlGeneratorInterface::class),
$apiClient,
@@ -272,6 +341,7 @@ class BpnAuthenticatorTest extends TestCase
$completenessChecker,
$this->createStub(LoggerInterface::class),
new EmployeeDomainMatcher(['ep-reisen.de']),
$messageBus,
);
}