From 7be0260b2958fdfa50fc00ca13d663b3329dc589 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 11:41:21 +0200 Subject: [PATCH] feat(core) : RBAC #344 - API Platform Role CRUD nominal + validators --- src/Module/Core/Domain/Entity/Role.php | 64 +++++ tests/Module/Core/Api/RoleApiTest.php | 319 +++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 tests/Module/Core/Api/RoleApiTest.php diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 7583454..f5a2a31 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -4,12 +4,24 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Validator\Constraints as Assert; /** * Role RBAC : groupe nomme de permissions assignable a un utilisateur. @@ -18,27 +30,72 @@ use Doctrine\ORM\Mapping as ORM; * "personnalise" (cree par un administrateur). Seuls les roles personnalises * peuvent etre supprimes. */ +#[ApiResource( + operations: [ + new GetCollection( + normalizationContext: ['groups' => ['role:read']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Get( + normalizationContext: ['groups' => ['role:read']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Post( + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Patch( + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + new Delete( + // TODO ticket #345 : remplacer par is_granted('core.roles.manage') + security: "is_granted('ROLE_ADMIN')", + ), + ], + normalizationContext: ['groups' => ['role:read']], + denormalizationContext: ['groups' => ['role:write']], +)] +#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])] #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] #[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] +#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')] class Role { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['role:read'])] private ?int $id = null; #[ORM\Column(length: 100)] + #[Groups(['role:read', 'role:write'])] + #[Assert\NotBlank] + #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')] private string $code; #[ORM\Column(length: 255)] + #[Groups(['role:read', 'role:write'])] + #[Assert\NotBlank] private string $label; #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['role:read', 'role:write'])] private ?string $description = null; + // Volontairement exclu du groupe `role:write` : un client ne doit jamais + // pouvoir positionner ce flag via l'API. Seules les fixtures et migrations + // creent les roles systeme. #[ORM\Column(name: 'is_system', options: ['default' => false])] + #[Groups(['role:read'])] private bool $isSystem = false; /** @var Collection */ @@ -53,6 +110,7 @@ class Role // projection cachee (ticket a ouvrir a ce moment-la). #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] + #[Groups(['role:read', 'role:write'])] private Collection $permissions; public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null) @@ -84,6 +142,12 @@ class Role return $this->description; } + // Le getter est annote directement car la convention Symfony PropertyInfo + // strip le prefixe `is` et exposerait le champ sous le nom `system`. On + // pose donc un SerializedName explicite pour garantir la sortie JSON-LD + // sous `isSystem`, nom attendu par les clients de l'API. + #[Groups(['role:read'])] + #[SerializedName('isSystem')] public function isSystem(): bool { return $this->isSystem; diff --git a/tests/Module/Core/Api/RoleApiTest.php b/tests/Module/Core/Api/RoleApiTest.php new file mode 100644 index 0000000..c5683de --- /dev/null +++ b/tests/Module/Core/Api/RoleApiTest.php @@ -0,0 +1,319 @@ +getEm(); + + // Nettoyage defensif au cas ou un run precedent aurait laisse des restes. + $this->cleanupTestData(); + + // Permissions de test reutilisables (notamment pour le PATCH). + $p1 = new Permission('test.core.roles.view', 'View roles (test)', 'core'); + $p2 = new Permission('test.core.roles.manage', 'Manage roles (test)', 'core'); + $em->persist($p1); + $em->persist($p2); + + // Role custom existant : utilise pour les GET / PATCH / DELETE. + $editor = new Role('test_editor', 'Editeur (test)', false, 'Role de test editeur'); + $em->persist($editor); + + // Deuxieme role custom : pour enrichir les collections. + $viewer = new Role('test_viewer', 'Visualisateur (test)', false); + $em->persist($viewer); + + $em->flush(); + $em->clear(); + } + + protected function tearDown(): void + { + $this->cleanupTestData(); + parent::tearDown(); + } + + public function testPostCreatesCustomRoleAsAdmin(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_new_editor', + 'label' => 'Nouvel editeur', + 'description' => 'Role de test', + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertSame('test_new_editor', $data['code']); + self::assertSame('Nouvel editeur', $data['label']); + self::assertFalse($data['isSystem']); + + // Verification cote base : le role existe et isSystem = false. + $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_new_editor']); + self::assertNotNull($persisted); + self::assertFalse($persisted->isSystem()); + } + + public function testPostWithDuplicateCodeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + // `admin` est un role systeme charge par les fixtures. + 'code' => 'admin', + 'label' => 'Tentative de doublon', + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithInvalidCodeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + // Majuscules interdites par la regex snake_case. + 'code' => 'BadCode', + 'label' => 'Code invalide', + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithIsSystemTrueIgnoresItAndPersistsFalse(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('POST', '/api/roles', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'code' => 'test_sneaky', + 'label' => 'Tentative systeme', + 'isSystem' => true, + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + self::assertFalse($data['isSystem']); + + $persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_sneaky']); + self::assertNotNull($persisted); + self::assertFalse($persisted->isSystem()); + } + + public function testGetCollectionAsAdminReturnsRoles(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles'); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertArrayHasKey('member', $data); + // Au moins admin systeme + user systeme + test_editor + test_viewer. + self::assertGreaterThanOrEqual(2, $data['totalItems']); + $codes = array_column($data['member'], 'code'); + self::assertContains('test_editor', $codes); + } + + public function testGetCollectionFilterByIsSystemTrue(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles', [ + 'query' => ['isSystem' => 'true'], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + foreach ($data['member'] as $item) { + self::assertTrue($item['isSystem']); + } + $codes = array_column($data['member'], 'code'); + self::assertNotContains('test_editor', $codes); + self::assertNotContains('test_viewer', $codes); + } + + public function testGetItemReturnsAllReadFields(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($role); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/roles/'.$role->getId()); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame('test_editor', $data['code']); + self::assertSame('Editeur (test)', $data['label']); + self::assertSame('Role de test editeur', $data['description']); + self::assertFalse($data['isSystem']); + self::assertArrayHasKey('permissions', $data); + self::assertIsArray($data['permissions']); + } + + public function testPatchCustomRoleUpdatesLabelAndAddsPermission(): void + { + $em = $this->getEm(); + $role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertNotNull($role); + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.roles.view']); + self::assertNotNull($permission); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('PATCH', '/api/roles/'.$role->getId(), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => [ + 'label' => 'Editeur modifie', + 'permissions' => ['/api/permissions/'.$permission->getId()], + ], + ]); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(); + self::assertSame('Editeur modifie', $data['label']); + self::assertCount(1, $data['permissions']); + + // Verification cote base. + $em->clear(); + + /** @var Role $reloaded */ + $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); + self::assertSame('Editeur modifie', $reloaded->getLabel()); + self::assertCount(1, $reloaded->getPermissions()); + } + + public function testDeleteCustomRoleReturns204(): void + { + $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_viewer']); + self::assertNotNull($role); + $id = $role->getId(); + + $client = $this->authenticatedClient('admin', 'admin'); + $client->request('DELETE', '/api/roles/'.$id); + + self::assertResponseStatusCodeSame(204); + + $em = $this->getEm(); + $em->clear(); + self::assertNull($em->getRepository(Role::class)->find($id)); + } + + public function testUnauthenticatedGetCollectionReturns401(): void + { + $client = self::createClient(); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(401); + } + + public function testNonAdminGetCollectionReturns403(): void + { + $client = $this->authenticatedClient('alice', 'alice'); + $client->request('GET', '/api/roles'); + + self::assertResponseStatusCodeSame(403); + } + + /** + * Recupere l'EntityManager depuis le container courant. A utiliser a + * chaque appel : apres un createClient(), le kernel est reboote et tout + * EM precedemment capture est invalide. + */ + private function getEm(): EntityManagerInterface + { + if (!self::$kernel) { + self::bootKernel(); + } + + return self::getContainer()->get('doctrine')->getManager(); + } + + /** + * Purge les donnees de test (roles et permissions prefixees `test.`). + * Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les + * fixtures. + */ + private function cleanupTestData(): void + { + $em = $this->getEm(); + + // Ordre important : role_permission lie aux deux, on vide les roles + // custom d'abord (la jointure est cascade supprimee par Doctrine lors + // du remove() du cote proprietaire). En DQL bulk on passe par les + // entites, Doctrine genere les DELETE de la table de jointure. + $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(); + } + + /** + * Cree un client authentifie via /login_check (cookie BEARER pose par + * lexik_jwt_authentication et persiste automatiquement par BrowserKit). + */ + private function authenticatedClient(string $username, string $password): Client + { + $client = self::createClient(); + $response = $client->request('POST', '/login_check', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['username' => $username, 'password' => $password], + ]); + + self::assertContains( + $response->getStatusCode(), + [200, 204], + 'Login failed for '.$username.': '.$response->getStatusCode(), + ); + + return $client; + } +}