- SiteCollectionScopedExtension filtre /api/sites aux sites du user (name/adresse/CP/ville plus lisibles par un delegataire sites.view qui n'appartient pas a ces sites). Bypass via sites.bypass_scope. - UserSiteScopedExtension filtre /api/users aux users partageant au moins un site avec le caller. Empeche un delegataire de core.users.view d'enumerer l'organigramme complet + les sites de tous les tenants. - Helper createUserWithPermission rattache le user jetable a tous les sites fixtures, sinon le scoping le rend aveugle aux cibles. - test_target de UserRbacApiTest attache de meme aux sites pour rester visible depuis un caller non-admin. - testUserCannotSwitchToUnauthorizedSite : 403 -> 400 (anti-enumeration).
314 lines
12 KiB
PHP
314 lines
12 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 App\Module\Sites\Domain\Entity\Site;
|
|
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). On lui attache tous les sites
|
|
// fixtures pour rester visible depuis les callers non-admin munis de
|
|
// sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
|
|
// intersection de sites). Sans cela, un user `core.users.manage`
|
|
// sans site commun avec test_target recevrait un 404 sur le PATCH.
|
|
$target = new User();
|
|
$target->setUsername('test_target');
|
|
$target->setIsAdmin(false);
|
|
$target->setPassword($hasher->hashPassword($target, 'secret'));
|
|
foreach ($em->getRepository(Site::class)->findAll() as $site) {
|
|
$target->addSite($site);
|
|
}
|
|
$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());
|
|
}
|
|
|
|
// --- 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
|
|
// 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();
|
|
}
|
|
}
|