loginAdmin($client); $aliceId = $this->userId('alice'); $client->request('GET', '/api/users/'.$aliceId.'/rbac'); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertArrayHasKey('rbacRoles', $data); self::assertArrayHasKey('directPermissions', $data); self::assertArrayHasKey('effectivePermissions', $data); } public function testRbacRequiresAuthentication(): void { $client = self::createClient(); $aliceId = $this->userId('alice'); $client->request('GET', '/api/users/'.$aliceId.'/rbac'); self::assertResponseStatusCodeSame(401); } public function testAdminCanAssignRoleViaPatch(): void { $client = self::createClient(); $em = self::getContainer()->get(EntityManagerInterface::class); $roleCode = 'reviewer_'.uniqid(); $role = new Role($roleCode, 'Reviewer'); $em->persist($role); $target = $this->createUser($em, 'rbac-assign-'.uniqid()); $em->flush(); $roleId = $role->getId(); $targetId = $target->getId(); $this->loginAdmin($client); $client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [ 'CONTENT_TYPE' => 'application/merge-patch+json', ], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]])); self::assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); self::assertCount(1, $data['rbacRoles']); $em->clear(); $reloaded = $em->getRepository(User::class)->find($targetId); self::assertCount(1, $reloaded->getRbacRoles()); self::assertSame($roleCode, $reloaded->getRbacRoles()->first()->getCode()); } public function testPartialPatchDoesNotWipeOtherCollection(): void { $client = self::createClient(); $em = self::getContainer()->get(EntityManagerInterface::class); $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); self::assertInstanceOf(Permission::class, $permission); $role = new Role('auditor_'.uniqid(), 'Auditor'); $em->persist($role); // Dedicated user pre-loaded with a direct permission. $target = $this->createUser($em, 'rbac-partial-'.uniqid()); $target->addDirectPermission($permission); $em->flush(); $roleId = $role->getId(); $targetId = $target->getId(); $this->loginAdmin($client); // PATCH only rbacRoles — directPermissions is absent from the payload. $client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [ 'CONTENT_TYPE' => 'application/merge-patch+json', ], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]])); self::assertResponseIsSuccessful(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($targetId); self::assertCount(1, $reloaded->getRbacRoles(), 'Role should have been assigned'); self::assertCount(1, $reloaded->getDirectPermissions(), 'Direct permission must not be wiped by a partial PATCH'); } private function createUser(EntityManagerInterface $em, string $username): User { $user = new User(); $user->setUsername($username); $user->setPassword('x'); $user->setRoles(['ROLE_USER']); $em->persist($user); return $user; } private function loginAdmin(KernelBrowser $client): void { $em = self::getContainer()->get(EntityManagerInterface::class); $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); self::assertInstanceOf(User::class, $user); $client->loginUser($user); } private function userId(string $username): int { $em = self::getContainer()->get(EntityManagerInterface::class); $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); self::assertInstanceOf(User::class, $user); return $user->getId(); } }