test(core) : RBAC #345 - functional coverage voter + last admin guard
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
195
tests/Module/Core/Api/UserApiTest.php
Normal file
195
tests/Module/Core/Api/UserApiTest.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'exposition API Platform de l'entite User.
|
||||
*
|
||||
* Strategie :
|
||||
* - Les fixtures chargent 3 users : admin (is_admin=true), alice, bob.
|
||||
* - Les tests de lecture s'appuient sur les fixtures sans les modifier.
|
||||
* - Les tests de suppression et de guard "dernier admin" creent des users
|
||||
* additionnels via EntityManager, purges en tearDown.
|
||||
* - On ne supprime JAMAIS les users fixture (admin / alice / bob).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class UserApiTest extends AbstractApiTestCase
|
||||
{
|
||||
private const TEST_USER_PREFIX = 'test_';
|
||||
private const TEST_ROLE_PREFIX = 'test_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user