Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un élève en cours d'année sans passer par un import CSV. Cette fonctionnalité pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui sera réutilisé par l'import CSV en masse (Story 3.1). L'email est désormais optionnel pour les élèves : si fourni, une invitation est envoyée (User::inviter) ; sinon l'élève est créé avec le statut INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur et l'affectation à la classe sont atomiques (transaction DBAL). Côté frontend, la page /admin/students offre liste paginée, recherche, filtrage par classe, création via modale (avec détection de doublons côté serveur), et changement de classe avec optimistic update.
176 lines
5.1 KiB
PHP
176 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
|
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Administration\Infrastructure\Security\StudentVoter;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
|
|
final class StudentVoterTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
|
|
|
private StudentVoter $voter;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->voter = new StudentVoter();
|
|
}
|
|
|
|
#[Test]
|
|
public function itAbstainsForUnrelatedAttributes(): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
|
|
|
|
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
|
|
|
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessToUnauthenticatedUsers(): void
|
|
{
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn(null);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessToNonSecurityUserInstances(): void
|
|
{
|
|
$user = $this->createMock(UserInterface::class);
|
|
$user->method('getRoles')->willReturn([Role::ADMIN->value]);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
// --- VIEW ---
|
|
|
|
#[Test]
|
|
#[DataProvider('allowedRolesProvider')]
|
|
public function itGrantsViewToAllowedRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('deniedRolesProvider')]
|
|
public function itDeniesViewToOtherRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
// --- CREATE ---
|
|
|
|
#[Test]
|
|
#[DataProvider('allowedRolesProvider')]
|
|
public function itGrantsCreateToAllowedRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::CREATE]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('deniedRolesProvider')]
|
|
public function itDeniesCreateToOtherRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::CREATE]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
// --- MANAGE ---
|
|
|
|
#[Test]
|
|
#[DataProvider('allowedRolesProvider')]
|
|
public function itGrantsManageToAllowedRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::MANAGE]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
#[DataProvider('deniedRolesProvider')]
|
|
public function itDeniesManageToOtherRoles(string $role): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
|
|
$result = $this->voter->vote($token, null, [StudentVoter::MANAGE]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
// --- Data Providers ---
|
|
|
|
/**
|
|
* @return iterable<string, array{string}>
|
|
*/
|
|
public static function allowedRolesProvider(): iterable
|
|
{
|
|
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
|
yield 'ADMIN' => [Role::ADMIN->value];
|
|
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
|
}
|
|
|
|
/**
|
|
* @return iterable<string, array{string}>
|
|
*/
|
|
public static function deniedRolesProvider(): iterable
|
|
{
|
|
yield 'PROF' => [Role::PROF->value];
|
|
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
|
yield 'PARENT' => [Role::PARENT->value];
|
|
yield 'ELEVE' => [Role::ELEVE->value];
|
|
}
|
|
|
|
private function tokenWithSecurityUser(string $role): TokenInterface
|
|
{
|
|
$securityUser = new SecurityUser(
|
|
UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
|
|
'test@example.com',
|
|
'hashed_password',
|
|
TenantId::fromString(self::TENANT_ID),
|
|
[$role],
|
|
);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($securityUser);
|
|
|
|
return $token;
|
|
}
|
|
}
|