From d1e4402368d6b196374a41088a5647d8b04eeb5d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 16:10:11 +0200 Subject: [PATCH] feat(core) : RBAC #345 - expose effectivePermissions via /api/me - Ajoute #[Groups(['me:read'])] sur getEffectivePermissions() dans User.php - Fixe la serialisation de isAdmin : le prefixe "is" etait strip par Symfony, expose desormais via le getter avec #[SerializedName('isAdmin')] + groups lecture, la propriete conserve uniquement le groupe d'ecriture user:rbac:write - Cree MeApiTest avec 4 tests fonctionnels (isAdmin admin, permissions vides user, 401 sans auth, effectivePermissions avec role portant une permission) --- src/Module/Core/Domain/Entity/User.php | 10 +- tests/Module/Core/Api/MeApiTest.php | 169 +++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/Module/Core/Api/MeApiTest.php diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 89cb073..77a8f12 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -68,7 +68,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private ?string $username = null; #[ORM\Column(name: 'is_admin', options: ['default' => false])] - #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])] + // Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac. + // Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer + // la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName). + #[Groups(['user:rbac:write'])] private bool $isAdmin = false; /** @@ -169,6 +172,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $roles; } + // Groupes de lecture + nom serialise explicite pour eviter que Symfony + // ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin". + #[Groups(['me:read', 'user:list', 'user:rbac:read'])] + #[SerializedName('isAdmin')] public function isAdmin(): bool { return $this->isAdmin; @@ -245,6 +252,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * * @return list */ + #[Groups(['me:read'])] public function getEffectivePermissions(): array { $codes = []; diff --git a/tests/Module/Core/Api/MeApiTest.php b/tests/Module/Core/Api/MeApiTest.php new file mode 100644 index 0000000..ad41657 --- /dev/null +++ b/tests/Module/Core/Api/MeApiTest.php @@ -0,0 +1,169 @@ +cleanupTestData(); + parent::tearDown(); + } + + /** + * L'admin (isAdmin=true, role systeme sans permission explicite) doit + * obtenir un payload /me avec isAdmin=true et effectivePermissions=[]. + */ + public function testMeEndpointReturnsIsAdminAndEffectivePermissionsForAdmin(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertSame('admin', $data['username'], 'Le champ username doit etre "admin".'); + self::assertTrue($data['isAdmin'], 'isAdmin doit etre true pour l\'admin fixture.'); + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertIsArray($data['effectivePermissions'], 'effectivePermissions doit etre un tableau JSON.'); + // Le role systeme admin n'a pas de permissions explicites : tableau vide attendu. + self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour l\'admin sans permissions explicites.'); + } + + /** + * Un utilisateur standard (isAdmin=false, role user sans permission) doit + * obtenir isAdmin=false et effectivePermissions=[]. + */ + public function testMeEndpointReturnsEmptyPermissionsForStandardUser(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertFalse($data['isAdmin'], 'isAdmin doit etre false pour alice.'); + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour un user sans role avec permission.'); + } + + /** + * Une requete non authentifiee sur /api/me doit retourner 401. + */ + public function testMeEndpointRequiresAuthentication(): void + { + $client = self::createClient(); + $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Un user rattache a un role portant la permission `core.users.view` doit + * retrouver cette permission dans effectivePermissions, triee alphabetiquement. + */ + public function testMeEndpointReturnsEffectivePermissionsForUserWithRolePermissions(): void + { + // --- Preparation des donnees de test --- + self::bootKernel(); + $em = $this->getEm(); + + $this->cleanupTestData(); + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $permission = new Permission('test.me.core.users.view', 'View users (test me)', 'core'); + $em->persist($permission); + + $role = new Role('test_me_viewer', 'Viewer (test me)', false); + $role->addPermission($permission); + $em->persist($role); + + $user = new User(); + $user->setUsername('test_me_viewer_user'); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, 'secret')); + $user->addRbacRole($role); + $em->persist($user); + + $em->flush(); + $em->clear(); + + // --- Appel API --- + $client = $this->authenticatedClient('test_me_viewer_user', 'secret'); + $response = $client->request('GET', '/api/me', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + self::assertResponseIsSuccessful(); + + $data = $response->toArray(); + + self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.'); + self::assertContains( + 'test.me.core.users.view', + $data['effectivePermissions'], + 'effectivePermissions doit contenir le code de permission du role attribue.', + ); + + // Verifie le tri alphabetique (contrat spec section 9 ticket-343). + $sorted = $data['effectivePermissions']; + $copy = $sorted; + sort($copy); + self::assertSame($copy, $sorted, 'effectivePermissions doit etre trie alphabetiquement.'); + } + + /** + * Purge les entites de test creees par les methodes ci-dessus. + * Ordre : users d'abord (FK vers roles), puis roles, puis permissions. + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + $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(); + } +}