feat: derive ROLE_EMPLOYEE from the account's email domain

This commit is contained in:
2026-09-13 12:49:51 +02:00
parent 443a3ed248
commit fab89dead6
7 changed files with 221 additions and 8 deletions
+36 -2
View File
@@ -11,6 +11,7 @@ use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Security\BpnAuthenticator;
use App\Security\Crypt;
use App\Security\EmployeeDomainMatcher;
use App\Security\Role;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
@@ -196,6 +197,38 @@ class BpnAuthenticatorTest extends TestCase
return $attributes;
}
public function testStaffEmailDomainGrantsTheEmployeeRole(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
// Effective, so it displaces the customer fallback the same account would get otherwise.
self::assertSame(['ROLE_USER', Role::EMPLOYEE], $user->getRoles());
}
public function testAnotherEmailDomainStillFallsBackToCustomer(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
public function testEmployeeRoleIsWithdrawnWhenTheAddressIsNoLongerStaff(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
private function authenticator(
CrmAttributes $crmAttributes,
?User $existing,
@@ -238,13 +271,14 @@ class BpnAuthenticatorTest extends TestCase
$crypt,
$completenessChecker,
$this->createStub(LoggerInterface::class),
new EmployeeDomainMatcher(['ep-reisen.de']),
);
}
private function loadUser(BpnAuthenticator $authenticator): User
private function loadUser(BpnAuthenticator $authenticator, string $email = '[email protected]'): User
{
$request = new Request();
$request->request->set('_username', '[email protected]');
$request->request->set('_username', $email);
$request->request->set('_password', 'secret');
$badge = $authenticator->authenticate($request)->getBadge(UserBadge::class);
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\EmployeeDomainMatcher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* The domain is matched exactly, never as a suffix: a suffix match would hand ROLE_EMPLOYEE to
* anybody able to register a domain ending in the configured one.
*/
class EmployeeDomainMatcherTest extends TestCase
{
#[DataProvider('addresses')]
public function testRecognisesAnEmployeeAddress(?string $email, bool $expected): void
{
$matcher = new EmployeeDomainMatcher(['ep-reisen.de']);
self::assertSame($expected, $matcher->isEmployee($email));
}
/**
* @return iterable<string, array{0: ?string, 1: bool}>
*/
public static function addresses(): iterable
{
yield 'the configured domain' => ['[email protected]', true];
yield 'mixed case is still the same domain' => ['[email protected]', true];
yield 'plus addressing does not touch the domain' => ['[email protected]', true];
yield 'an at sign in the local part' => ['"odd@name"@ep-reisen.de', true];
yield 'another domain' => ['[email protected]', false];
yield 'a subdomain is not the domain' => ['[email protected]', false];
yield 'a domain merely ending in it' => ['[email protected]', false];
yield 'the domain as a prefix' => ['[email protected]', false];
yield 'no at sign at all' => ['ep-reisen.de', false];
yield 'empty' => ['', false];
yield 'null' => [null, false];
}
public function testWithoutConfiguredDomainsNobodyIsAnEmployee(): void
{
self::assertFalse((new EmployeeDomainMatcher([]))->isEmployee('[email protected]'));
}
public function testConfiguredDomainsAreNormalised(): void
{
$matcher = new EmployeeDomainMatcher([' EP-Reisen.DE ', '@example.org']);
self::assertTrue($matcher->isEmployee('[email protected]'));
self::assertTrue($matcher->isEmployee('[email protected]'));
}
}
+29
View File
@@ -93,6 +93,35 @@ class RoleTest extends TestCase
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
}
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
{
// The claim does not come from the CRM, but it travels the same path as one.
self::assertSame([Role::EMPLOYEE], Role::sync([], [Role::EMPLOYEE]));
}
public function testEmployeeIsRevokedOnceItIsNoLongerClaimed(): void
{
// Somebody whose address left the staff domain: no claim, so the role goes, and with no
// effective role left the fallback returns.
self::assertSame([Role::CUSTOMER], Role::sync([Role::EMPLOYEE], []));
}
public function testEmployeeDoesNotShortCircuitTheNominationOfAnAdministrativeRole(): void
{
self::assertSame(
[Role::EMPLOYEE, Role::pending(Role::ADMIN)],
Role::sync([], [Role::EMPLOYEE, Role::ADMIN]),
);
}
public function testEmployeeAndTeamerCoexist(): void
{
self::assertSame(
[Role::TEAMER, Role::EMPLOYEE],
Role::sync([], [Role::TEAMER, Role::EMPLOYEE]),
);
}
public function testEveryRoleAndNominationHasALabel(): void
{
$labels = Role::labels();