Files
Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php
THOLOT DECHENE Matthieu e8c2789435
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
RBAC - Système complet de permissions (Backend + Frontend) (#7)
## Résumé

Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.

### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)

### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions

### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)

## Tickets Lesstime

- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur

## Test plan

- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-04-17 12:34:38 +00:00

222 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Security\PermissionVoter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* Tests unitaires du PermissionVoter RBAC.
*
* Le voter est teste via sa methode publique vote() qui retourne une des
* trois constantes VoterInterface : ACCESS_GRANTED, ACCESS_DENIED, ACCESS_ABSTAIN.
* - ACCESS_ABSTAIN : supports() a retourne false (attribut non-RBAC).
* - ACCESS_GRANTED / ACCESS_DENIED : voteOnAttribute() a ete invoque.
*
* Aucun acces base de donnees : toutes les entites sont construites en memoire.
*
* @internal
*/
class PermissionVoterTest extends TestCase
{
private PermissionVoter $voter;
protected function setUp(): void
{
$this->voter = new PermissionVoter();
}
// ---------------------------------------------------------------
// Abstention : attributs non-RBAC
// ---------------------------------------------------------------
/**
* Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule,
* ne correspond pas au pattern snake_case minuscule avec point.
*/
public function testAbstainsOnRoleAdminAttribute(): void
{
$user = $this->buildUser(username: 'alice', isAdmin: false);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, ['ROLE_ADMIN']);
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
}
/**
* Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules,
* pas de point de separation conforme au pattern RBAC.
*/
public function testAbstainsOnIsAuthenticatedAttribute(): void
{
$user = $this->buildUser(username: 'alice', isAdmin: false);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']);
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
}
/**
* Le voter s'abstient sur des attributs malformes : sans point ou avec
* majuscules.
*/
#[DataProvider('malformedAttributeProvider')]
public function testAbstainsOnMalformedAttribute(string $attribute): void
{
$user = $this->buildUser(username: 'alice', isAdmin: false);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, [$attribute]);
$this->assertSame(
VoterInterface::ACCESS_ABSTAIN,
$result,
sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute),
);
}
/**
* @return array<string, array{string}>
*/
public static function malformedAttributeProvider(): array
{
return [
'sans point' => ['nodot'],
'majuscule milieu' => ['HAS.UPPERCASE'],
'commence chiffre' => ['1core.users.view'],
'chaine vide' => [''],
];
}
// ---------------------------------------------------------------
// Refus : utilisateur non reconnu
// ---------------------------------------------------------------
/**
* Refuse l'acces quand le token ne porte pas une instance de User metier
* (ex: InMemoryUser de Symfony).
*/
public function testDeniesWhenUserIsNotAUserEntity(): void
{
$inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']);
$token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles());
$result = $this->voter->vote($token, null, ['core.users.view']);
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
}
// ---------------------------------------------------------------
// Bypass admin
// ---------------------------------------------------------------
/**
* Accorde l'acces systematiquement a un administrateur, meme sans aucune
* permission explicite assignee.
*/
public function testGrantsForAdminBypass(): void
{
// Admin sans role ni permission directe : le bypass doit suffire.
$user = $this->buildUser(username: 'admin', isAdmin: true);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, ['core.users.view']);
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
}
// ---------------------------------------------------------------
// Permissions effectives via role
// ---------------------------------------------------------------
/**
* Accorde l'acces quand l'utilisateur possede la permission exacte via un role.
*/
public function testGrantsWhenUserHasExactPermission(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
$role = new Role('viewer', 'Viewer');
$role->addPermission($permission);
$user = $this->buildUser(username: 'alice', isAdmin: false);
$user->addRbacRole($role);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, ['core.users.view']);
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
}
/**
* Refuse l'acces quand l'utilisateur possede une permission differente de
* celle demandee.
*/
public function testDeniesWhenUserLacksPermission(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
$role = new Role('viewer', 'Viewer');
$role->addPermission($permission);
$user = $this->buildUser(username: 'alice', isAdmin: false);
$user->addRbacRole($role);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
// L'utilisateur a core.users.view mais pas core.roles.manage.
$result = $this->voter->vote($token, null, ['core.roles.manage']);
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
}
// ---------------------------------------------------------------
// Permissions directes (hors roles)
// ---------------------------------------------------------------
/**
* Accorde l'acces via une permission directe (assignee sans passer par un role).
*/
public function testGrantsForDirectPermission(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
$user = $this->buildUser(username: 'bob', isAdmin: false);
$user->addDirectPermission($permission);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$result = $this->voter->vote($token, null, ['core.users.view']);
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
/**
* Construit un User metier minimal sans persistance.
*/
private function buildUser(string $username, bool $isAdmin): User
{
$user = new User();
$user->setUsername($username);
$user->setIsAdmin($isAdmin);
// Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface.
$user->setPassword('hashed_placeholder');
return $user;
}
}