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' => 'user@example.com', '_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(); } }