293 lines
10 KiB
PHP
293 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Security;
|
|
|
|
use App\BusProNet\ApiClient;
|
|
use App\BusProNet\Model\CrmAttributes;
|
|
use App\BusProNet\Model\CrmSelectionGroup;
|
|
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;
|
|
use Doctrine\ORM\EntityRepository;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
|
|
|
/**
|
|
* Covers what a login does to an account: BusPro owns the roles and the hotel codes, but a
|
|
* CRM claim must never grant an administrative role on its own.
|
|
*/
|
|
class BpnAuthenticatorTest extends TestCase
|
|
{
|
|
public function testNewAccountIsSeededFromTheCrm(): void
|
|
{
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
|
|
null,
|
|
$persisted,
|
|
);
|
|
|
|
$user = $this->loadUser($authenticator);
|
|
|
|
self::assertSame($persisted, $user);
|
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
|
|
self::assertSame(['SSL'], $user->getHotelCodes());
|
|
}
|
|
|
|
public function testAdministrativeClaimIsOnlyANominationUntilItIsApproved(): void
|
|
{
|
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
|
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
|
|
$existing,
|
|
$persisted,
|
|
);
|
|
|
|
$user = $this->loadUser($authenticator);
|
|
|
|
self::assertSame($existing, $persisted, 'a login must not create a second account');
|
|
self::assertSame(
|
|
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
|
|
$user->getRoles(),
|
|
);
|
|
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
|
|
}
|
|
|
|
public function testApprovedRoleSurvivesTheNextLogin(): void
|
|
{
|
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
|
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
|
|
$existing,
|
|
$persisted,
|
|
);
|
|
|
|
self::assertSame(
|
|
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
|
|
$this->loadUser($authenticator)->getRoles(),
|
|
);
|
|
}
|
|
|
|
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
|
|
{
|
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
|
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
|
|
|
// Nothing is claimed any more, so nothing is held — and an account without an effective
|
|
// role is a customer.
|
|
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
|
|
}
|
|
|
|
public function testRoleGainedInBusProIsGrantedOnLogin(): void
|
|
{
|
|
$existing = (new User('[email protected]'))->setRoles([Role::CUSTOMER]);
|
|
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([Role::TEAMER], []),
|
|
$existing,
|
|
$persisted,
|
|
);
|
|
|
|
// The case myep-team depends on: somebody becomes a Teamer after their account exists.
|
|
self::assertSame(['ROLE_USER', Role::TEAMER], $this->loadUser($authenticator)->getRoles());
|
|
}
|
|
|
|
public function testHotelCodesAreResyncedOnEveryLogin(): void
|
|
{
|
|
$existing = (new User('[email protected]'))
|
|
->setRoles([Role::HOUSE_MANAGER])
|
|
->setHotelCodes(['DKS', 'SSL'])
|
|
;
|
|
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([Role::HOUSE_MANAGER], ['DKS']),
|
|
$existing,
|
|
$persisted,
|
|
);
|
|
|
|
self::assertSame(['DKS'], $this->loadUser($authenticator)->getHotelCodes());
|
|
}
|
|
|
|
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
|
|
{
|
|
$existing = (new User('[email protected]'))
|
|
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
|
->setHotelCodes(['DKS'])
|
|
;
|
|
|
|
$persisted = null;
|
|
// No selection groups at all: BusPro always answers with the full attribute tree, so
|
|
// this is a degraded payload and not a revocation of everything.
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([], [], selectionGroups: []),
|
|
$existing,
|
|
$persisted,
|
|
);
|
|
|
|
$user = $this->loadUser($authenticator);
|
|
|
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
|
self::assertSame(['DKS'], $user->getHotelCodes());
|
|
}
|
|
|
|
public function testDegradedCrmResponseStillGivesANewAccountTheFallbackRole(): void
|
|
{
|
|
$persisted = null;
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([], [], selectionGroups: []),
|
|
null,
|
|
$persisted,
|
|
);
|
|
|
|
self::assertSame(['ROLE_USER', Role::CUSTOMER], $this->loadUser($authenticator)->getRoles());
|
|
}
|
|
|
|
public function testANewAccountIsOnlyRegisteredOnceItCarriesItsPassword(): void
|
|
{
|
|
$persisted = null;
|
|
$persistedPassword = null;
|
|
|
|
// A degraded payload is what makes syncFromCrm() log, and that log write reaches the
|
|
// database. An account registered before the profile is complete would be written out
|
|
// half-built, and the user table rejects it: password is NOT NULL.
|
|
$authenticator = $this->authenticator(
|
|
$this->crmAttributes([], [], selectionGroups: []),
|
|
null,
|
|
$persisted,
|
|
$persistedPassword,
|
|
);
|
|
|
|
$this->loadUser($authenticator);
|
|
|
|
self::assertInstanceOf(User::class, $persisted);
|
|
self::assertSame('encrypted', $persistedPassword);
|
|
}
|
|
|
|
/**
|
|
* @param string[] $roles
|
|
* @param string[] $hotelCodes
|
|
* @param CrmSelectionGroup[] $selectionGroups only their presence matters here — an empty
|
|
* set is what marks a response as degraded
|
|
*/
|
|
private function crmAttributes(array $roles, array $hotelCodes, ?array $selectionGroups = null): CrmAttributes
|
|
{
|
|
$attributes = new CrmAttributes();
|
|
$attributes->roles = $roles;
|
|
$attributes->hotelCodes = $hotelCodes;
|
|
$attributes->selectionGroups = $selectionGroups ?? [new CrmSelectionGroup()];
|
|
|
|
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,
|
|
?User &$persisted,
|
|
?string &$persistedPassword = null,
|
|
): BpnAuthenticator {
|
|
$personalData = new PersonalData();
|
|
$personalData->personId = 42;
|
|
$personalData->addressId = 4711;
|
|
|
|
$apiClient = $this->createStub(ApiClient::class);
|
|
$apiClient->method('getPersonalData')->willReturn($personalData);
|
|
$apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
|
|
|
|
$repository = $this->createStub(EntityRepository::class);
|
|
$repository->method('findOneBy')->willReturn($existing);
|
|
|
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
|
$entityManager->method('getRepository')->willReturn($repository);
|
|
$entityManager
|
|
->method('persist')
|
|
->willReturnCallback(static function (object $entity) use (&$persisted, &$persistedPassword): void {
|
|
$persisted = $entity;
|
|
// Snapshot rather than a reference: what matters is what the account looked like
|
|
// at the moment it was registered, not what it grew into afterwards.
|
|
$persistedPassword = $entity instanceof User ? $entity->getPassword() : null;
|
|
})
|
|
;
|
|
|
|
$crypt = $this->createStub(Crypt::class);
|
|
$crypt->method('encrypt')->willReturn('encrypted');
|
|
|
|
$completenessChecker = $this->createStub(ProfileCompletenessChecker::class);
|
|
$completenessChecker->method('isComplete')->willReturn(true);
|
|
|
|
return new BpnAuthenticator(
|
|
$this->createStub(UrlGeneratorInterface::class),
|
|
$apiClient,
|
|
$entityManager,
|
|
$crypt,
|
|
$completenessChecker,
|
|
$this->createStub(LoggerInterface::class),
|
|
new EmployeeDomainMatcher(['ep-reisen.de']),
|
|
);
|
|
}
|
|
|
|
private function loadUser(BpnAuthenticator $authenticator, string $email = '[email protected]'): User
|
|
{
|
|
$request = new Request();
|
|
$request->request->set('_username', $email);
|
|
$request->request->set('_password', 'secret');
|
|
|
|
$badge = $authenticator->authenticate($request)->getBadge(UserBadge::class);
|
|
self::assertInstanceOf(UserBadge::class, $badge);
|
|
|
|
$user = $badge->getUser();
|
|
self::assertInstanceOf(User::class, $user);
|
|
|
|
return $user;
|
|
}
|
|
}
|