Files
Coltura/tests/Module/Core/Api/RoleApiTest.php

394 lines
15 KiB
PHP

<?php
declare(strict_types=1);
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\Security\SystemRoles;
use Doctrine\ORM\EntityManagerInterface;
/**
* Tests fonctionnels de l'exposition API Platform de l'entite Role (CRUD nominal).
*
* Strategie :
* - Les roles systeme `admin` et `user` sont deja charges par les fixtures
* (cf. AppFixtures::ensureSystemRole). On ne les touche JAMAIS.
* - Les roles et permissions crees pour les tests ont le prefixe `test.` et
* sont purges en setUp + tearDown par DQL prefixe.
* - Les cas 403 sur role systeme et 400 sur modification de `code` sont
* reportes a la Task 3 (RoleProcessor) et ne sont PAS testes ici.
*
* @internal
*/
final class RoleApiTest extends ApiTestCase
{
// Prefixe pour les roles de test : `test_` (underscore) parce que les
// codes de role doivent matcher `/^[a-z][a-z0-9_]*$/` (pas de point
// autorise, contrairement aux permissions).
private const TEST_ROLE_PREFIX = 'test_';
// Prefixe pour les permissions de test : `test.` (point) parce que les
// codes de permission doivent contenir au moins un `.` (convention
// module.resource.action validee dans le constructeur Permission).
private const TEST_PERMISSION_PREFIX = 'test.';
// Bascule explicite sur le nouveau comportement API Platform 5 pour
// eviter la deprecation emise a la creation du client de test.
protected static ?bool $alwaysBootKernel = true;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$em = $this->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 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);
}
/**
* 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();
// 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).
$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;
}
}