Files
Coltura/tests/Module/Core/Api/UserRbacApiTest.php
Matthieu 3c7dc88fe7 feat(core) : RBAC #344 - UserRbacProcessor + endpoint /users/{id}/rbac
Ajoute une operation Patch dediee `PATCH /api/users/{id}/rbac` (nom
`user_rbac_patch`) qui accepte exclusivement les champs RBAC isAdmin,
roles et directPermissions via le groupe user:rbac:write. L'endpoint est
separe volontairement du Patch profil existant pour isoler la modification
des droits de celle des donnees profil (decision 0fc4e16).

UserRbacProcessor delegue au PersistProcessor Doctrine decore et applique
une garde auto-suicide : un admin ne peut pas retirer ses propres droits
administrateur (compare l'etat entrant a l'etat UnitOfWork). La garde
'dernier admin' globale est reportee au ticket #345.

La propriete Doctrine $roles est renommee $rbacRoles pour eviter la
collision avec UserInterface::getRoles() (qui renvoie list<string>) lors
de la normalization API Platform. La cle JSON reste `roles` grace a
SerializedName, le contrat API est inchange.

Tests : 6 unitaires (UserRbacProcessorTest) + 8 fonctionnels
(UserRbacApiTest) couvrant promotion admin, remplacement des collections
roles/directPermissions, 401/403, filtrage du groupe denormalization
(`username` ignore), preservation de isAdmin sur le Patch profil, et
garde auto-suicide.
2026-04-15 14:17:18 +02:00

272 lines
10 KiB
PHP

<?php
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;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Tests fonctionnels de l'endpoint RBAC dedie `PATCH /api/users/{id}/rbac`.
*
* Strategie de donnees :
* - On cree des users, roles et permissions prefixes `test_` / `test.`
* en setUp et on les purge en tearDown.
* - On ne touche JAMAIS aux fixtures (admin / alice / bob). Les cas qui
* ont besoin d'un user standard authentifie s'appuient sur alice sans
* modification d'etat.
* - Les users de test incluent un admin dedie pour le cas d'auto-suicide,
* pour ne pas risquer de corrompre l'admin fixture.
*
* @internal
*/
final class UserRbacApiTest extends AbstractApiTestCase
{
private const TEST_USER_PREFIX = 'test_';
private const TEST_ROLE_PREFIX = 'test_';
private const TEST_PERMISSION_PREFIX = 'test.';
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$em = $this->getEm();
$this->cleanupTestData();
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin).
$target = new User();
$target->setUsername('test_target');
$target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret'));
$em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).
$selfAdmin = new User();
$selfAdmin->setUsername('test_self_admin');
$selfAdmin->setIsAdmin(true);
$selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret'));
$em->persist($selfAdmin);
// Role custom pour tester le remplacement de la collection roles.
$role = new Role('test_editor', 'Editeur (test)', false);
$em->persist($role);
// Permission custom pour tester directPermissions.
$permission = new Permission('test.core.users.view', 'View users (test)', 'core');
$em->persist($permission);
$em->flush();
$em->clear();
}
protected function tearDown(): void
{
$this->cleanupTestData();
parent::tearDown();
}
public function testPatchRbacPromotesUserToAdmin(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertTrue($reloaded->isAdmin());
}
public function testPatchRbacReplacesRolesCollection(): void
{
$em = $this->getEm();
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
$role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($target);
self::assertNotNull($role);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['roles' => ['/api/roles/'.$role->getId()]],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertCount(1, $reloaded->getRbacRoles());
self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode());
}
public function testPatchRbacReplacesDirectPermissionsCollection(): void
{
$em = $this->getEm();
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']);
self::assertNotNull($target);
self::assertNotNull($permission);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertCount(1, $reloaded->getDirectPermissions());
self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode());
}
public function testPatchRbacAsStandardUserReturns403(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testPatchRbacUnauthenticatedReturns401(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = self::createClient();
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseStatusCodeSame(401);
}
public function testPatchRbacIgnoresUsernameField(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$targetId = $target->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'username' => 'test_target_renamed',
'isAdmin' => true,
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($targetId);
// `username` n'est pas dans `user:rbac:write` : ignore en denormalization.
self::assertSame('test_target', $reloaded->getUsername());
// `isAdmin` est bien applique.
self::assertTrue($reloaded->isAdmin());
}
public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void
{
// Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`,
// donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir.
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$targetId = $target->getId();
self::assertFalse($target->isAdmin());
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$targetId, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
// Peu importe le code : le champ ne doit tout simplement pas bouger.
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertFalse($reloaded->isAdmin());
}
public function testPatchRbacSelfRemovingAdminReturns400(): void
{
// On utilise le user admin dedie (test_self_admin) pour ne pas
// corrompre l'admin fixture en cas de bug.
$em = $this->getEm();
$selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']);
self::assertNotNull($selfAdmin);
$selfAdminId = $selfAdmin->getId();
$client = $this->authenticatedClient('test_self_admin', 'secret');
$client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => false],
]);
self::assertResponseStatusCodeSame(400);
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($selfAdminId);
self::assertTrue($reloaded->isAdmin());
}
private function cleanupTestData(): void
{
$em = $this->getEm();
// Ordre important : delier les collections avant de supprimer les
// entites referencees pour que les FK cascade s'appliquent via le
// schema PostgreSQL.
$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();
}
}