getEm(); $this->cleanupTestData(); /** @var UserPasswordHasherInterface $hasher */ $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); // User cible standard (non admin). $target = new User(); $target->setUsername('test_target'); $target->setIsAdmin(false); $target->setPassword($hasher->hashPassword($target, 'secret')); $em->persist($target); // User admin dedie pour le cas d'auto-suicide (pas l'admin fixture). $selfAdmin = new User(); $selfAdmin->setUsername('test_self_admin'); $selfAdmin->setIsAdmin(true); $selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret')); $em->persist($selfAdmin); // Role custom pour tester le remplacement de la collection roles. $role = new Role('test_editor', 'Editeur (test)', false); $em->persist($role); // Permission custom pour tester directPermissions. $permission = new Permission('test.core.users.view', 'View users (test)', 'core'); $em->persist($permission); $em->flush(); $em->clear(); } protected function tearDown(): void { $this->cleanupTestData(); parent::tearDown(); } public function testPatchRbacPromotesUserToAdmin(): void { $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertNotNull($target); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => true], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertTrue($reloaded->isAdmin()); } public function testPatchRbacReplacesRolesCollection(): void { $em = $this->getEm(); $target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); $role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); self::assertNotNull($target); self::assertNotNull($role); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['roles' => ['/api/roles/'.$role->getId()]], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertCount(1, $reloaded->getRbacRoles()); self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode()); } public function testPatchRbacReplacesDirectPermissionsCollection(): void { $em = $this->getEm(); $target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']); self::assertNotNull($target); self::assertNotNull($permission); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertCount(1, $reloaded->getDirectPermissions()); self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode()); } public function testPatchRbacAsStandardUserReturns403(): void { $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertNotNull($target); $client = $this->authenticatedClient('alice', 'alice'); $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => true], ]); self::assertResponseStatusCodeSame(403); } public function testPatchRbacUnauthenticatedReturns401(): void { $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertNotNull($target); $client = self::createClient(); $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => true], ]); self::assertResponseStatusCodeSame(401); } public function testPatchRbacIgnoresUsernameField(): void { $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertNotNull($target); $targetId = $target->getId(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$targetId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'username' => 'test_target_renamed', 'isAdmin' => true, ], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->find($targetId); // `username` n'est pas dans `user:rbac:write` : ignore en denormalization. self::assertSame('test_target', $reloaded->getUsername()); // `isAdmin` est bien applique. self::assertTrue($reloaded->isAdmin()); } public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void { // Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`, // donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir. $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); self::assertNotNull($target); $targetId = $target->getId(); self::assertFalse($target->isAdmin()); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$targetId, [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => true], ]); // Peu importe le code : le champ ne doit tout simplement pas bouger. $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->find($targetId); self::assertFalse($reloaded->isAdmin()); } public function testPatchRbacSelfRemovingAdminReturns400(): void { // On utilise le user admin dedie (test_self_admin) pour ne pas // corrompre l'admin fixture en cas de bug. $em = $this->getEm(); $selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']); self::assertNotNull($selfAdmin); $selfAdminId = $selfAdmin->getId(); $client = $this->authenticatedClient('test_self_admin', 'secret'); $client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => false], ]); self::assertResponseStatusCodeSame(400); $em = $this->getEm(); $em->clear(); /** @var User $reloaded */ $reloaded = $em->getRepository(User::class)->find($selfAdminId); self::assertTrue($reloaded->isAdmin()); } private function cleanupTestData(): void { $em = $this->getEm(); // Ordre important : delier les collections avant de supprimer les // entites referencees pour que les FK cascade s'appliquent via le // schema PostgreSQL. $em->createQuery( 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' )->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute(); $em->createQuery( 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); $em->createQuery( 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute(); } }