From 6df4316950f8ed57ea9962174bcf2f9c394f9fba Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:16:30 +0200 Subject: [PATCH] test(core) : RBAC #345 - functional coverage voter + last admin guard --- tests/Module/Core/Api/AbstractApiTestCase.php | 67 ++++++ tests/Module/Core/Api/PermissionApiTest.php | 64 +++++- tests/Module/Core/Api/RoleApiTest.php | 79 +++++++ tests/Module/Core/Api/UserApiTest.php | 195 ++++++++++++++++++ tests/Module/Core/Api/UserRbacApiTest.php | 34 +++ 5 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 tests/Module/Core/Api/UserApiTest.php diff --git a/tests/Module/Core/Api/AbstractApiTestCase.php b/tests/Module/Core/Api/AbstractApiTestCase.php index 3a15d6b..c4993dc 100644 --- a/tests/Module/Core/Api/AbstractApiTestCase.php +++ b/tests/Module/Core/Api/AbstractApiTestCase.php @@ -6,7 +6,11 @@ namespace App\Tests\Module\Core\Api; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; +use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Entity\User; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; /** * Classe de base pour les tests fonctionnels API Platform du module Core. @@ -18,6 +22,9 @@ use Doctrine\ORM\EntityManagerInterface; * (cookie BEARER HTTP-only pose par lexik_jwt_authentication). * - `getEm()` : recupere l'EntityManager depuis le container courant. * A rappeler apres chaque createClient() car le kernel est reboote. + * - `createUserWithPermission()` : cree un user non-admin jetable portant + * une permission specifique via un role custom. Utile pour prouver qu'un + * non-admin avec la permission obtient 200, et sans la permission 403. * * @internal */ @@ -63,4 +70,64 @@ abstract class AbstractApiTestCase extends ApiTestCase return $client; } + + /** + * Cree un utilisateur non-admin portant une permission specifique via un + * role custom jetable. A utiliser dans les tests fonctionnels qui doivent + * prouver qu'un non-admin avec la permission requise obtient 200, et + * sans la permission obtient 403. + * + * Le user et le role sont persistes avec un suffixe aleatoire pour eviter + * les collisions inter-tests. Le password est "testpass". + * + * Prerequis : la permission identifiee par $permissionCode doit exister en + * base (seeder via `app:sync-permissions`). Si elle est introuvable, le test + * echoue immediatement avec un message explicite. + * + * @param string $permissionCode Le code de la permission (ex: "core.users.view") + * + * @return array{username: string, password: string} Les identifiants pour authenticatedClient() + */ + protected function createUserWithPermission(string $permissionCode): array + { + if (!self::$kernel) { + self::bootKernel(); + } + + $em = $this->getEm(); + + /** @var null|Permission $permission */ + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]); + + self::assertNotNull( + $permission, + sprintf( + 'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.', + $permissionCode, + ), + ); + + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $username = 'testuser_'.$suffix; + $password = 'testpass'; + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); + $role->addPermission($permission); + $em->persist($role); + + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, $password)); + $user->addRbacRole($role); + $em->persist($user); + + $em->flush(); + $em->clear(); + + return ['username' => $username, 'password' => $password]; + } } diff --git a/tests/Module/Core/Api/PermissionApiTest.php b/tests/Module/Core/Api/PermissionApiTest.php index f097b95..d9c6609 100644 --- a/tests/Module/Core/Api/PermissionApiTest.php +++ b/tests/Module/Core/Api/PermissionApiTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Api; use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; +use App\Module\Core\Domain\Entity\User; /** * Tests fonctionnels de l'exposition API Platform de l'entite Permission. @@ -172,9 +174,69 @@ final class PermissionApiTest extends AbstractApiTestCase self::assertResponseStatusCodeSame(403); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testListPermissionsAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.permissions.view doit pouvoir lister. + $credentials = $this->createUserWithPermission('core.permissions.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/permissions'); + + self::assertResponseIsSuccessful(); + } + + public function testListPermissionsAsStandardUserReturns403(): void + { + // alice n'a aucune permission RBAC : acces refuse. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions'); + + self::assertResponseStatusCodeSame(403); + } + + public function testGetPermissionAsUserWithViewPermissionReturns200(): void + { + // Recupere l'id d'une permission existante pour construire l'URL GET item. + $permission = $this->getEm()->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $credentials = $this->createUserWithPermission('core.permissions.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseIsSuccessful(); + } + + public function testGetPermissionAsStandardUserReturns403(): void + { + $permission = $this->getEm()->getRepository(Permission::class) + ->findOneBy(['code' => 'test.core.users.view']) + ; + self::assertNotNull($permission); + + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/permissions/'.$permission->getId()); + + self::assertResponseStatusCodeSame(403); + } + private function cleanupTestPermissions(): void { - $this->getEm()->createQuery( + $em = $this->getEm(); + + // Purge des users et roles jetables crees par createUserWithPermission(). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', 'testuser_%')->execute(); + + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', 'test_%')->execute(); + + $em->createQuery( 'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix' )->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute(); } diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php index 205ad94..f11bfa3 100644 --- a/tests/Module/Core/Api/RoleApiTest.php +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -368,6 +368,85 @@ final class RoleApiTest extends AbstractApiTestCase self::assertResponseStatusCodeSame(403); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testListRolesAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.roles.view doit pouvoir lister les roles. + $credentials = $this->createUserWithPermission('core.roles.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + } + + public function testListRolesAsUserWithOnlyManagePermissionReturns403(): void + { + // Un user avec uniquement core.roles.manage ne peut PAS lister (list/get + // exige core.roles.view, cf. spec section 3 ticket-345). + $credentials = $this->createUserWithPermission('core.roles.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + public function testListRolesAsStandardUserReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateRoleAsUserWithManagePermissionReturns201(): void + { + // Un non-admin portant core.roles.manage doit pouvoir creer un role. + $credentials = $this->createUserWithPermission('core.roles.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_created_by_manager', + 'label' => 'Role cree par manager (test)', + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertSame('test_created_by_manager', $data['code']); + } + + public function testCreateRoleAsUserWithOnlyViewPermissionReturns403(): void + { + // Un user avec core.roles.view uniquement ne peut pas creer (POST exige .manage). + $credentials = $this->createUserWithPermission('core.roles.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_shouldnotcreate', + 'label' => 'Ne doit pas etre cree', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateRoleAsStandardUserReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_shouldnotcreate_alice', + 'label' => 'Ne doit pas etre cree', + ], + ]); + + self::assertResponseStatusCodeSame(403); + } + /** * Purge les donnees de test (roles et permissions prefixees `test.`). * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les diff --git a/tests/Module/Core/Api/UserApiTest.php b/tests/Module/Core/Api/UserApiTest.php new file mode 100644 index 0000000..5b80fbd --- /dev/null +++ b/tests/Module/Core/Api/UserApiTest.php @@ -0,0 +1,195 @@ +cleanupTestData(); + parent::tearDown(); + } + + // --- Tests lecture collection --- + + public function testListUsersAsAdminReturns200(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/users'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + // Au moins 3 users fixture. + self::assertGreaterThanOrEqual(3, $data['totalItems']); + } + + public function testListUsersAsUserWithViewPermissionReturns200(): void + { + // Un non-admin portant core.users.view doit pouvoir lister les users. + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('GET', '/api/users'); + + self::assertResponseIsSuccessful(); + } + + public function testListUsersAsStandardUserReturns403(): void + { + // alice n'a aucune permission RBAC : acces refuse. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/users'); + + self::assertResponseStatusCodeSame(403); + } + + // --- Tests suppression --- + + public function testDeleteNonAdminUserAsAdminReturns204(): void + { + // Confirme que la suppression d'un user non-admin fonctionne. + $em = $this->getEm(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $target = new User(); + $target->setUsername('test_deletable_user'); + $target->setIsAdmin(false); + $target->setPassword($hasher->hashPassword($target, 'secret')); + $em->persist($target); + $em->flush(); + $targetId = $target->getId(); + $em->clear(); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/users/'.$targetId); + + self::assertResponseStatusCodeSame(204); + + // Verification cote base : le user n'existe plus. + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(User::class)->find($targetId)); + } + + public function testDeleteSecondAdminReturns204(): void + { + // Quand il y a 2 admins, supprimer le second est autorise (garde non declenchee). + $em = $this->getEm(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $secondAdmin = new User(); + $secondAdmin->setUsername('test_second_admin'); + $secondAdmin->setIsAdmin(true); + $secondAdmin->setPassword($hasher->hashPassword($secondAdmin, 'secret')); + $em->persist($secondAdmin); + $em->flush(); + $secondAdminId = $secondAdmin->getId(); + $em->clear(); + + // Auth en tant qu'admin fixture, supprime le second admin. + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/users/'.$secondAdminId); + + self::assertResponseStatusCodeSame(204); + + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(User::class)->find($secondAdminId)); + } + + public function testDeleteLastAdminReturns400(): void + { + // Scenario "dernier admin global" : un seul admin existe (fixture admin). + // Il tente de se supprimer lui-meme -> garde activee -> 400. + $em = $this->getEm(); + + /** @var null|User $fixtureAdmin */ + $fixtureAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertNotNull($fixtureAdmin, 'L\'user admin fixture doit exister.'); + $fixtureAdminId = $fixtureAdmin->getId(); + + // Garantit qu'il n'y a qu'un seul admin au moment du test : + // s'assure que test_second_admin n'existe pas (tearDown le purge, mais + // soyons defensifs si un test precedent n'a pas nettoye). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix AND u.username != :admin' + )->setParameters(['prefix' => 'test_%', 'admin' => 'admin'])->execute(); + + // Auth en tant que l'admin fixture et tente l'auto-suppression. + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('DELETE', '/api/users/'.$fixtureAdminId); + + self::assertResponseStatusCodeSame(400); + + // Verification cote base : l'admin fixture doit toujours exister. + $em = $this->getEm(); + $em->clear(); + self::assertNotNull( + $em->getRepository(User::class)->find($fixtureAdminId), + 'Le dernier admin ne doit PAS etre supprime.', + ); + } + + public function testDeleteAsStandardUserReturns403(): void + { + $em = $this->getEm(); + + /** @var null|User $alice */ + $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + self::assertNotNull($alice); + + /** @var null|User $bob */ + $bob = $em->getRepository(User::class)->findOneBy(['username' => 'bob']); + self::assertNotNull($bob); + + // alice sans permission ne peut pas supprimer bob. + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('DELETE', '/api/users/'.$bob->getId()); + + self::assertResponseStatusCodeSame(403); + } + + /** + * Purge les entites de test creees par cette suite. + * Ne touche JAMAIS aux fixtures (admin / alice / bob). + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + // Purge des users jetables crees par les tests (y compris testuser_ de createUserWithPermission). + $em->createQuery( + 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' + )->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute(); + + // Purge des roles jetables crees par createUserWithPermission. + $em->createQuery( + 'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix' + )->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute(); + } +} diff --git a/tests/Module/Core/Api/UserRbacApiTest.php b/tests/Module/Core/Api/UserRbacApiTest.php index 9984e54..5d825c1 100644 --- a/tests/Module/Core/Api/UserRbacApiTest.php +++ b/tests/Module/Core/Api/UserRbacApiTest.php @@ -224,6 +224,40 @@ final class UserRbacApiTest extends AbstractApiTestCase self::assertFalse($reloaded->isAdmin()); } + // --- Tests voter RBAC : non-admin avec / sans permission --- + + public function testPatchRbacAsUserWithManagePermissionReturns200(): void + { + // Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac. + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $credentials = $this->createUserWithPermission('core.users.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => false], + ]); + + self::assertResponseIsSuccessful(); + } + + public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void + { + // Un user avec core.users.view uniquement ne peut pas ecrire via /rbac. + $target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']); + self::assertNotNull($target); + + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isAdmin' => true], + ]); + + self::assertResponseStatusCodeSame(403); + } + public function testPatchRbacSelfRemovingAdminReturns400(): void { // On utilise le user admin dedie (test_self_admin) pour ne pas