feat: align role assignment logic with myep-team

This commit is contained in:
Björn Fromme
2026-08-19 12:14:09 +02:00
parent 5a3957e143
commit 0c667d6b69
23 changed files with 1109 additions and 527 deletions
+73 -33
View File
@@ -7,62 +7,102 @@ namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
/**
* Covers the role policy: BusPro backend users can edit their own CRM selections, so a claim
* must never grant an administrative role on its own.
*/
class RoleTest extends TestCase
{
public function testPrivilegedRolesAreNeverImported(): void
public function testAdministrativeClaimOnlyProducesANomination(): void
{
$roles = Role::filterImportable([
Role::TEAMER,
Role::ADMIN,
Role::GROUPS_ADMIN,
Role::GROUPS_MANAGER,
Role::HOUSE_MANAGER,
]);
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
self::assertSame(
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
$roles,
);
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
}
public function testResultIsADedupedList(): void
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
{
// CrmAttributesResponseParser applies array_unique(), which preserves keys — a
// non-list would be persisted as a JSON object instead of an array.
$roles = Role::filterImportable([0 => Role::ADMIN, 2 => Role::TEAMER, 5 => Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $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]);
self::assertSame([Role::TEAMER], $roles);
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
}
public function testAccountWithOnlyPrivilegedRolesFallsBackToCustomer(): void
public function testRevokedRoleIsNotImmediatelyNominatedAgain(): void
{
self::assertSame([Role::CUSTOMER], Role::filterImportable([Role::ADMIN]));
self::assertSame([Role::CUSTOMER], Role::filterImportable([]));
self::assertSame([Role::CUSTOMER], Role::sync([Role::ADMIN], []));
}
public function testAssignedOnlyDropsTheImplicitRoleUser(): void
public function testAccountWithoutAnEffectiveRoleFallsBackToCustomer(): void
{
$roles = Role::assignedOnly([Role::USER, Role::TEAMER, Role::GROUPS_ADMIN]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
// The value is JSON-encoded into the userinfo response and must not become an object.
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
// 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]),
);
self::assertSame([Role::CUSTOMER], Role::sync([], []));
}
public function testCombineKeepsEachHalfInItsOwnLane(): void
public function testCustomerIsAFallbackAndNotABaseline(): void
{
$roles = Role::combine([Role::TEAMER, Role::ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
// The ADMIN from the synced half and the TEAMER from the privileged half are discarded.
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
self::assertSame(array_keys($roles), range(0, \count($roles) - 1));
self::assertSame([Role::TEAMER], Role::sync([Role::CUSTOMER], [Role::TEAMER]));
}
public function testCombineDropsTheImplicitRoleUser(): void
public function testUnknownClaimsAndTheImplicitRoleUserAreIgnored(): void
{
self::assertSame([Role::TEAMER], Role::combine([Role::USER, Role::TEAMER], []));
self::assertSame(
[Role::TEAMER],
Role::sync([Role::USER, Role::TEAMER], [Role::TEAMER, 'ROLE_SOMETHING_ELSE']),
);
}
public function testEveryRoleHasALabel(): void
public function testApprovalTurnsTheNominationIntoTheRole(): void
{
self::assertSame(Role::ALL, array_keys(Role::labels()));
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
// The customer fallback goes with it: the account now holds an effective role.
self::assertSame([Role::ADMIN], $roles);
}
public function testApprovingARoleWithoutANominationIsRefused(): void
{
$this->expectException(\InvalidArgumentException::class);
Role::approve([Role::TEAMER], Role::ADMIN);
}
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
{
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
}
public function testEveryRoleAndNominationHasALabel(): void
{
$labels = Role::labels();
foreach (Role::ALL as $role) {
self::assertArrayHasKey($role, $labels);
}
foreach (Role::ADMINISTRATIVE as $role) {
self::assertArrayHasKey(Role::pending($role), $labels);
}
}
}