Files
myep-team/tests/Security/BpnAuthenticatorTest.php
T

241 lines
9.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\CrmAttributeGroup;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\UserDataHandler;
use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use App\Security\BpnAuthenticator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
#[AllowMockObjectsWithoutExpectations]
class BpnAuthenticatorTest extends TestCase
{
private ApiClient&Stub $apiClient;
private UserDataHandler&MockObject $userDataHandler;
protected function setUp(): void
{
$this->apiClient = $this->createStub(ApiClient::class);
$this->userDataHandler = $this->createMock(UserDataHandler::class);
}
public function testUnknownUserWithoutClaimedRolesIsNeverCreated(): void
{
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn(null);
$this->userDataHandler->expects($this->never())->method('createLocalUser');
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->expectException(UserNotFoundException::class);
$this->loadUser();
}
public function testExistingUserWithoutClaimedRolesIsBlockedAndReturned(): void
{
$user = (new User())->setRoles(['ROLE_TEAM_ADMIN']);
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
$this->userDataHandler
->expects($this->once())
->method('disableForRevokedCrmRoles')
->with($user)
;
// returned rather than refused, so the UserChecker can explain the block
$this->assertSame($user, $this->loadUser());
}
/**
* This guard is what stands between a degraded response and a mass revocation: with the
* roles led by the CRM, a login that reached updateLocalUser() on an empty payload would
* strip every role of every user logging in, one at a time.
*/
public function testResponseWithoutAttributeGroupsRefusesTheLoginWithoutBlocking(): void
{
$user = (new User())->setRoles(['ROLE_TEAM_ADMIN']);
// an empty payload carries no roles either and must not read as a revocation
$this->stubApiClient(new CrmAttributesResponse());
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
try {
$this->loadUser();
$this->fail('Expected the login to be refused');
} catch (UserNotFoundException) {
}
$this->assertSame(['ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
}
/**
* The CRM must not be able to undo a deletion, in either direction: neither by
* refreshing the account's data nor by blocking it further.
*/
public function testDeletedUserIsReturnedWithoutAnyCrmSync(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDeleted();
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectClaimedRoles')->willReturn(['ROLE_TEAMER']);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
$this->userDataHandler->expects($this->never())->method('createLocalUser');
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
// returned rather than refused, so the UserChecker can explain the deletion
$this->assertSame($user, $this->loadUser());
}
public function testDeletedUserIsNotBlockedWhenTheCrmRevokedEveryRole(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$user->setDeleted();
$this->stubApiClient($this->createCrmAttributes());
$this->userDataHandler->method('collectClaimedRoles')->willReturn([]);
$this->userDataHandler->method('findLocalUser')->willReturn($user);
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
$this->assertSame($user, $this->loadUser());
$this->assertFalse($user->isDisabled());
}
public function testTheLoginLeadsToTheDeeplinkTheUserCameFrom(): void
{
$request = $this->createRequest();
$request->getSession()->set('_security.main.target_path', 'https://team.example.com/administrative/assignment/detail/abc');
$response = $this->createAuthenticator()->onAuthenticationSuccess($request, $this->createToken(), 'main');
$this->assertSame('https://team.example.com/administrative/assignment/detail/abc', $response->headers->get('Location'));
// used once: a later login in the same session starts from the default route again
$this->assertFalse($request->getSession()->has('_security.main.target_path'));
}
public function testWithoutADeeplinkTheLoginLeadsToTheDefaultRoute(): void
{
$response = $this->createAuthenticator()->onAuthenticationSuccess($this->createRequest(), $this->createToken(), 'main');
$this->assertSame('/default-route', $response->headers->get('Location'));
}
public function testAnHtmxRequestIsNotKeptAsTheDeeplink(): void
{
// a modal opened after the session expired: the firewall saved its url just before
$request = $this->createRequest();
$request->headers->set('HX-Request', 'true');
$request->getSession()->set('_security.main.target_path', 'https://team.example.com/admin/system/fee/edit/1');
$this->createAuthenticator()->start($request);
$this->assertFalse($request->getSession()->has('_security.main.target_path'));
}
public function testAPageRequestIsKeptAsTheDeeplink(): void
{
$request = $this->createRequest();
$request->getSession()->set('_security.main.target_path', 'https://team.example.com/administrative/assignment');
$response = $this->createAuthenticator()->start($request);
$this->assertSame('/login', $response->headers->get('Location'));
$this->assertTrue($request->getSession()->has('_security.main.target_path'));
}
private function createCrmAttributes(): CrmAttributesResponse
{
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
}
private function stubApiClient(CrmAttributesResponse $crmAttributes): void
{
$this->apiClient->method('getProfile')->willReturn(new ProfileResponse());
$this->apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
}
private function createAuthenticator(): BpnAuthenticator
{
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
$urlGenerator->method('generate')->willReturnCallback(
static fn (string $route): string => 'app_security_login' === $route ? '/login' : '/default-route'
);
return new BpnAuthenticator(
$urlGenerator,
$this->createStub(EntityManagerInterface::class),
$this->apiClient,
$this->createStub(LoggerInterface::class),
$this->userDataHandler,
$this->createStub(RequiredTeamerCheckRegistry::class),
);
}
private function createRequest(): Request
{
$request = new Request(request: [
'_username' => '[email protected]',
'_password' => 'secret',
'_csrf_token' => 'token',
]);
$request->setSession(new Session(new MockArraySessionStorage()));
return $request;
}
private function createToken(): PostAuthenticationToken
{
$user = (new User())->setRoles(['ROLE_TEAM_ADMIN']);
return new PostAuthenticationToken($user, 'main', $user->getRoles());
}
private function loadUser(): ?User
{
$passport = $this->createAuthenticator()->authenticate($this->createRequest());
/** @var UserBadge $badge */
$badge = $passport->getBadge(UserBadge::class);
/* @var User $user */
return $badge->getUser();
}
}