feat: reserve administrative roles for staff email addresses

This commit is contained in:
2026-09-17 18:13:52 +02:00
parent 725e32daab
commit 540cd8eeb7
11 changed files with 230 additions and 70 deletions
+43 -15
View File
@@ -43,7 +43,7 @@ class BpnAuthenticatorTest extends TestCase
{
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
$this->crmAttributes([Role::HOUSE_MANAGER, Role::TEAMER], ['SSL', 'SSL']),
null,
$persisted,
);
@@ -51,7 +51,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator);
self::assertSame($persisted, $user);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], $user->getRoles());
self::assertSame(['SSL'], $user->getHotelCodes());
}
@@ -61,7 +61,7 @@ class BpnAuthenticatorTest extends TestCase
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
@@ -70,7 +70,7 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame($existing, $persisted, 'a login must not create a second account');
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)],
$user->getRoles(),
);
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
@@ -78,24 +78,24 @@ class BpnAuthenticatorTest extends TestCase
public function testApprovedRoleSurvivesTheNextLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER],
$this->loadUser($authenticator)->getRoles(),
);
}
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
@@ -140,7 +140,7 @@ class BpnAuthenticatorTest extends TestCase
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
{
$existing = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
->setRoles([Role::TEAMER, Role::HOUSE_MANAGER])
->setHotelCodes(['DKS'])
;
@@ -155,7 +155,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
self::assertSame(['DKS'], $user->getHotelCodes());
}
@@ -240,12 +240,40 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
public function testEmployeeOnlyClaimFromAnotherDomainIsIgnored(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN, Role::TEAMER], []), null, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
// Not even a nomination, so there is nothing for an administrator to be told about.
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $this->dispatched);
}
public function testEmployeeOnlyRoleIsRevokedWhenTheAddressIsNotStaff(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
$user = $this->loadUser($authenticator, '[email protected]');
// Only the EMPLOYEE_ONLY role goes; a Hausleitung does not need a staff address.
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
}
public function testANewNominationIsAnnouncedOnce(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
$user = $this->loadUser($authenticator);
$user = $this->loadUser($authenticator, '[email protected]');
self::assertCount(1, $this->dispatched);
$message = $this->dispatched[0];
@@ -257,11 +285,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAStandingNominationIsNotAnnouncedAgain(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::pending(Role::ADMIN)]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
$this->loadUser($authenticator, '[email protected]');
// The nomination has not changed, so there is nothing new to tell an administrator about.
self::assertSame([], $this->dispatched);
@@ -269,11 +297,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
$this->loadUser($authenticator, '[email protected]');
self::assertSame([], $this->dispatched);
}
+76 -20
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
@@ -15,46 +16,46 @@ class RoleTest extends TestCase
{
public function testAdministrativeClaimOnlyProducesANomination(): void
{
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
$roles = Role::sync([], [Role::HOUSE_MANAGER, Role::GROUPS_ADMIN, Role::TEAMER, Role::EMPLOYEE]);
self::assertSame(
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
[Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)],
$roles,
);
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::TEAMER, Role::EMPLOYEE], Role::effectiveOnly($roles));
}
public function testCustomerExpertClaimOnlyProducesANomination(): void
{
$roles = Role::sync([], [Role::CUSTOMER_EXPERT]);
$roles = Role::sync([], [Role::EMPLOYEE, Role::CUSTOMER_EXPERT]);
self::assertSame([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], $roles);
self::assertSame([Role::CUSTOMER], Role::effectiveOnly($roles));
self::assertSame([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], $roles);
self::assertSame([Role::EMPLOYEE], Role::effectiveOnly($roles));
self::assertSame(
[Role::CUSTOMER_EXPERT => 'KO-Experte'],
[Role::CUSTOMER_EXPERT => 'KO-Expert:in'],
Role::nominatedFrom($roles),
);
}
public function testApprovedCustomerExpertDisplacesTheCustomerFallback(): void
public function testCustomerExpertCanBeApprovedForStaff(): void
{
$roles = Role::approve([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], Role::CUSTOMER_EXPERT);
$roles = Role::approve([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], Role::CUSTOMER_EXPERT);
self::assertSame([Role::CUSTOMER_EXPERT], $roles);
self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles);
}
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
{
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
}
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
{
// Both halves go: BusPro is the source of truth for the granted role as much as for
// the nomination.
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN, Role::pending(Role::HOUSE_MANAGER)], [Role::TEAMER]);
self::assertSame([Role::TEAMER], $roles);
}
@@ -69,8 +70,8 @@ class RoleTest extends TestCase
// The nomination stays visible — it is what an approver acts on — but grants nothing,
// so the account is a customer in the meantime.
self::assertSame(
[Role::pending(Role::ADMIN), Role::CUSTOMER],
Role::sync([], [Role::ADMIN]),
[Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER],
Role::sync([], [Role::HOUSE_MANAGER]),
);
self::assertSame([Role::CUSTOMER], Role::sync([], []));
}
@@ -90,10 +91,10 @@ class RoleTest extends TestCase
public function testApprovalTurnsTheNominationIntoTheRole(): void
{
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
$roles = Role::approve([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::HOUSE_MANAGER);
// The customer fallback goes with it: the account now holds an effective role.
self::assertSame([Role::ADMIN], $roles);
self::assertSame([Role::HOUSE_MANAGER], $roles);
}
public function testApprovingARoleWithoutANominationIsRefused(): void
@@ -105,11 +106,11 @@ class RoleTest extends TestCase
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
{
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
self::assertSame([Role::pending(Role::HOUSE_MANAGER)], Role::pendingOnly($roles));
self::assertSame([Role::HOUSE_MANAGER => 'Hausleitung'], Role::nominatedFrom($roles));
}
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
@@ -133,6 +134,61 @@ class RoleTest extends TestCase
);
}
/**
* @return iterable<string, array{string}>
*/
public static function employeeOnlyRoles(): iterable
{
foreach (Role::EMPLOYEE_ONLY as $role) {
yield $role => [$role];
}
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyClaimWithoutEmployeeIsNotNominated(string $role): void
{
self::assertSame([Role::TEAMER], Role::sync([], [Role::TEAMER, $role]));
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleIsRevokedWithoutEmployee(string $role): void
{
// The CRM still claims it, but the account is no longer staff: the claim counts as not
// made, and a role approved back when it was staff goes with it.
self::assertSame(
[Role::TEAMER],
Role::sync([Role::TEAMER, Role::EMPLOYEE, $role], [Role::TEAMER, $role]),
);
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleSurvivesTheNextSyncForStaff(string $role): void
{
self::assertSame(
[Role::EMPLOYEE, $role],
Role::sync([Role::EMPLOYEE, $role], [Role::EMPLOYEE, $role]),
);
}
public function testHouseManagerDoesNotNeedEmployee(): void
{
// A Hausleitung signs in with the hotel's own address.
self::assertSame([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::sync([], [Role::HOUSE_MANAGER]));
self::assertSame([Role::HOUSE_MANAGER], Role::sync([Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER]));
}
public function testStaleEmployeeOnlyNominationWithoutEmployeeCannotBeApproved(): void
{
// A marker from before the rule, still stored until the account's next login.
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame([], Role::nominatedFrom($roles));
$this->expectException(\InvalidArgumentException::class);
Role::approve($roles, Role::ADMIN);
}
public function testEmployeeAndTeamerCoexist(): void
{
self::assertSame(