213 lines
7.4 KiB
PHP
213 lines
7.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Security;
|
|
|
|
use App\Security\Role;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
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 testAdministrativeClaimOnlyProducesANomination(): void
|
|
{
|
|
$roles = Role::sync([], [Role::HOUSE_MANAGER, Role::GROUPS_ADMIN, Role::TEAMER, Role::EMPLOYEE]);
|
|
|
|
self::assertSame(
|
|
[Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)],
|
|
$roles,
|
|
);
|
|
self::assertSame([Role::TEAMER, Role::EMPLOYEE], Role::effectiveOnly($roles));
|
|
}
|
|
|
|
public function testCustomerExpertClaimOnlyProducesANomination(): void
|
|
{
|
|
$roles = Role::sync([], [Role::EMPLOYEE, Role::CUSTOMER_EXPERT]);
|
|
|
|
self::assertSame([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], $roles);
|
|
self::assertSame([Role::EMPLOYEE], Role::effectiveOnly($roles));
|
|
self::assertSame(
|
|
[Role::CUSTOMER_EXPERT => 'KO-Expert:in'],
|
|
Role::nominatedFrom($roles),
|
|
);
|
|
}
|
|
|
|
public function testCustomerExpertCanBeApprovedForStaff(): void
|
|
{
|
|
$roles = Role::approve([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], Role::CUSTOMER_EXPERT);
|
|
|
|
self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles);
|
|
}
|
|
|
|
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
|
{
|
|
$roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);
|
|
|
|
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::GROUPS_ADMIN, Role::pending(Role::HOUSE_MANAGER)], [Role::TEAMER]);
|
|
|
|
self::assertSame([Role::TEAMER], $roles);
|
|
}
|
|
|
|
public function testRevokedRoleIsNotImmediatelyNominatedAgain(): void
|
|
{
|
|
self::assertSame([Role::CUSTOMER], Role::sync([Role::ADMIN], []));
|
|
}
|
|
|
|
public function testAccountWithoutAnEffectiveRoleFallsBackToCustomer(): void
|
|
{
|
|
// 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::HOUSE_MANAGER), Role::CUSTOMER],
|
|
Role::sync([], [Role::HOUSE_MANAGER]),
|
|
);
|
|
self::assertSame([Role::CUSTOMER], Role::sync([], []));
|
|
}
|
|
|
|
public function testCustomerIsAFallbackAndNotABaseline(): void
|
|
{
|
|
self::assertSame([Role::TEAMER], Role::sync([Role::CUSTOMER], [Role::TEAMER]));
|
|
}
|
|
|
|
public function testUnknownClaimsAndTheImplicitRoleUserAreIgnored(): void
|
|
{
|
|
self::assertSame(
|
|
[Role::TEAMER],
|
|
Role::sync([Role::USER, Role::TEAMER], [Role::TEAMER, 'ROLE_SOMETHING_ELSE']),
|
|
);
|
|
}
|
|
|
|
public function testApprovalTurnsTheNominationIntoTheRole(): void
|
|
{
|
|
$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::HOUSE_MANAGER], $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::HOUSE_MANAGER)];
|
|
|
|
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
|
self::assertSame([Role::pending(Role::HOUSE_MANAGER)], Role::pendingOnly($roles));
|
|
self::assertSame([Role::HOUSE_MANAGER => 'Hausleitung'], 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]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @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(
|
|
[Role::TEAMER, Role::EMPLOYEE],
|
|
Role::sync([], [Role::TEAMER, Role::EMPLOYEE]),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|