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' => SystemRoles::ADMIN_CODE, '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(4, $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 testDeleteCustomRoleAttachedToUserDoesNotDeleteUser(): void { // Scenario spec #344 sections 7 & 11 : supprimer un role custom rattache // a un user doit laisser le user en base (la FK user_role est nettoyee // par ON DELETE CASCADE, mais jamais le user lui-meme). $em = $this->getEm(); // Creer un user de test dedie et lui rattacher le role custom `test_editor`. $testUser = new User(); $testUser->setUsername('test_cascade_user'); // Le hashage du password est hors scope du test mais la colonne est NOT NULL. $testUser->setPassword('not-hashed-ok-for-test'); /** @var Role $editor */ $editor = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); self::assertNotNull($editor); $testUser->addRbacRole($editor); $em->persist($testUser); $em->flush(); $userId = $testUser->getId(); $editorId = $editor->getId(); $em->clear(); // DELETE du role editor via l'API. $client = $this->authenticatedClient('admin', 'admin'); $client->request('DELETE', '/api/roles/'.$editorId); self::assertResponseStatusCodeSame(204); // Verification : l'user existe toujours et sa collection de roles est vide. $em = $this->getEm(); /** @var null|User $refreshed */ $refreshed = $em->getRepository(User::class)->find($userId); self::assertNotNull($refreshed, 'L\'user ne doit PAS etre supprime par le cascade.'); self::assertCount(0, $refreshed->getRbacRoles(), 'La relation user_role doit etre nettoyee par le cascade.'); // Cleanup explicite : cleanupTestData() ne purge pas les users. $em->remove($refreshed); $em->flush(); } public function testDeleteSystemRoleReturns403(): void { $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); self::assertNotNull($role); $client = $this->authenticatedClient('admin', 'admin'); $client->request('DELETE', '/api/roles/'.$role->getId()); self::assertResponseStatusCodeSame(403); // Le role systeme doit toujours exister. $em = $this->getEm(); $em->clear(); self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE])); } public function testPatchSystemRoleLabelReturns200(): void { $em = $this->getEm(); $role = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); self::assertNotNull($role); $originalLabel = $role->getLabel(); $roleId = $role->getId(); $client = $this->authenticatedClient('admin', 'admin'); try { $response = $client->request('PATCH', '/api/roles/'.$roleId, [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['label' => 'Administrateur (modifie test)'], ]); self::assertResponseIsSuccessful(); $data = $response->toArray(); self::assertSame('Administrateur (modifie test)', $data['label']); self::assertSame(SystemRoles::ADMIN_CODE, $data['code']); self::assertTrue($data['isSystem']); } finally { // Restauration defensive du label original pour ne pas polluer // les tests suivants (les fixtures systeme sont partagees). $em = $this->getEm(); /** @var null|Role $reloaded */ $reloaded = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]); if (null !== $reloaded && $reloaded->getLabel() !== $originalLabel) { $reloaded->setLabel($originalLabel); $em->flush(); } } } public function testPatchRoleCodeChangeReturns400(): void { $role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']); self::assertNotNull($role); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/roles/'.$role->getId(), [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['code' => 'test_editor_renamed'], ]); self::assertResponseStatusCodeSame(400); // Verification cote base : le code d'origine n'a pas bouge. $em = $this->getEm(); $em->clear(); self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor'])); self::assertNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor_renamed'])); } 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); } // --- 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 * fixtures. */ private function cleanupTestData(): void { $em = $this->getEm(); // Le cascade FK de la migration #343 (ON DELETE CASCADE sur // role_permission.role_id et permission_id) nettoie automatiquement // role_permission lors du DELETE SQL emis par Doctrine, meme via DQL // bulk delete : le cascade est applique au niveau FK par PostgreSQL, // pas par l'Unit of Work Doctrine. Verifie par comptage avant/apres // runs successifs de la suite (stable a la ligne de base systeme). // Purge defensive des users de test crees par certains scenarios // (ex: testDeleteCustomRoleAttachedToUserDoesNotDeleteUser). Doit etre // fait AVANT la suppression des roles pour que le cascade FK ne soit // pas sollicite en ordre inverse. $em->createQuery( 'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix' )->setParameter('prefix', self::TEST_ROLE_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(); } }