nominatedUser(); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN')); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView); self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles())); } public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void { $user = $this->nominatedUser(); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); self::assertTrue($response->headers->has('HX-Redirect')); self::assertSame(['success'], array_column($controller->flashes, 'type')); } public function testARoleTheCrmNeverClaimedCannotBeApproved(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class)); $this->expectException(NotFoundHttpException::class); // ROLE_ADMIN is not nominated, so no hand-crafted request can grant it. $controller->index($this->nominatedUser(), Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST')); } public function testApprovingRoleAdminForYourOwnAccountIsRefused(): void { $user = (new User('admin@example.org'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN)]); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class), currentUser: $user); $this->expectException(AccessDeniedException::class); $controller->index($user, Role::ADMIN, Request::create('/admin/user/1/approve/ROLE_ADMIN', 'POST')); } public function testApprovingALesserRoleForYourOwnAccountIsAllowed(): void { $user = $this->nominatedUser(); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class), currentUser: $user); // Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the // rest grant less than they could grant themselves anyway. $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles())); } public function testPostWithAnInvalidTokenIsDenied(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class), tokenValid: false); $this->expectException(AccessDeniedException::class); $controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); } public function testApprovingForYourselfReissuesTheSecurityToken(): void { $user = $this->nominatedUser(); $tokenStorage = new TokenStorage(); $tokenStorage->setToken(new PostAuthenticationToken($user, 'main', $user->getRoles())); $controller = new TestableApproveRoleController( $this->createStub(EntityManagerInterface::class), $this->createStub(LoggerInterface::class), currentUser: $user, tokenStorage: $tokenStorage, ); $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); // Without this the next request would find the stored roles out of step with the token // and end the session, logging the approver out mid-action. self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []); self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []); } public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void { $other = $this->nominatedUser(); $tokenStorage = new TokenStorage(); $admin = (new User('admin@example.org'))->setRoles([Role::ADMIN]); $tokenStorage->setToken($originalToken = new PostAuthenticationToken($admin, 'main', $admin->getRoles())); $controller = new TestableApproveRoleController( $this->createStub(EntityManagerInterface::class), $this->createStub(LoggerInterface::class), currentUser: $admin, tokenStorage: $tokenStorage, ); $controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST')); self::assertSame($originalToken, $tokenStorage->getToken()); } private function nominatedUser(): User { return (new User('teamer@example.org'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]); } } final class TestableApproveRoleController extends ApproveRoleController { public ?string $renderedView = null; /** @var list */ public array $flashes = []; public function __construct( EntityManagerInterface $entityManager, LoggerInterface $logger, private readonly bool $tokenValid = true, private readonly ?UserInterface $currentUser = null, public readonly TokenStorageInterface $tokenStorage = new TokenStorage(), ) { parent::__construct($entityManager, $logger, $this->tokenStorage); } protected function getUser(): ?UserInterface { return $this->currentUser; } protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool { return $this->tokenValid; } /** * @param array $parameters */ protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderedView = $view; return new Response(); } protected function addFlash(string $type, mixed $message): void { $this->flashes[] = ['type' => $type, 'message' => $message]; } /** * @param array $parameters */ public function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string { return '/'.$route.'?'.http_build_query($parameters); } }