Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
460 lines
16 KiB
PHP
460 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Scolarite\Infrastructure\Security;
|
|
|
|
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
|
use App\Administration\Domain\Model\Subject\SubjectId;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
|
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
|
use App\Scolarite\Application\Service\AutorisationSaisieNotesChecker;
|
|
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
|
|
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
|
|
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\ClassSubjectPair;
|
|
use App\Scolarite\Domain\Model\TeacherReplacement\TeacherReplacement;
|
|
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryTeacherReplacementRepository;
|
|
use App\Scolarite\Infrastructure\Security\GradeVoter;
|
|
use App\Shared\Domain\Clock;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantContext;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use DateTimeImmutable;
|
|
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 GradeVoterTest extends TestCase
|
|
{
|
|
private TenantId $tenantId;
|
|
private ClassId $classId;
|
|
private SubjectId $subjectId;
|
|
private InMemoryTeacherReplacementRepository $replacementRepository;
|
|
private TenantContext $tenantContext;
|
|
private DateTimeImmutable $now;
|
|
private GradeVoter $voter;
|
|
|
|
/** @var array<string, bool> */
|
|
private array $affectationResults = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->tenantId = TenantId::generate();
|
|
$this->classId = ClassId::generate();
|
|
$this->subjectId = SubjectId::generate();
|
|
$this->replacementRepository = new InMemoryTeacherReplacementRepository();
|
|
$this->tenantContext = new TenantContext();
|
|
$this->now = new DateTimeImmutable('2026-04-13 10:00:00');
|
|
|
|
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
|
tenantId: InfraTenantId::fromString((string) $this->tenantId),
|
|
subdomain: 'test',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
));
|
|
|
|
$this->affectationResults = [];
|
|
$test = $this;
|
|
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
|
|
public function __construct(private readonly GradeVoterTest $test)
|
|
{
|
|
}
|
|
|
|
public function estAffecte(
|
|
UserId $teacherId,
|
|
ClassId $classId,
|
|
SubjectId $subjectId,
|
|
TenantId $tenantId,
|
|
): bool {
|
|
return $this->test->getAffectationResult((string) $teacherId);
|
|
}
|
|
};
|
|
|
|
$autorisationChecker = new AutorisationSaisieNotesChecker(
|
|
$affectationChecker,
|
|
$this->replacementRepository,
|
|
);
|
|
|
|
$clock = $this->createMock(Clock::class);
|
|
$clock->method('now')->willReturn($this->now);
|
|
|
|
$this->voter = new GradeVoter(
|
|
$autorisationChecker,
|
|
$this->tenantContext,
|
|
$clock,
|
|
);
|
|
}
|
|
|
|
public function getAffectationResult(string $teacherId): bool
|
|
{
|
|
return $this->affectationResults[$teacherId] ?? false;
|
|
}
|
|
|
|
private function setTeacherAffecte(UserId $teacherId): void
|
|
{
|
|
$this->affectationResults[(string) $teacherId] = true;
|
|
}
|
|
|
|
#[Test]
|
|
public function itAbstainsForUnrelatedAttributes(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, ['SOME_OTHER_ATTRIBUTE']);
|
|
|
|
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itAbstainsWhenSubjectIsNotAnEvaluation(): void
|
|
{
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value);
|
|
|
|
$result = $this->voter->vote($token, null, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessToUnauthenticatedUsers(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn(null);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesAccessToNonSecurityUser(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$user = $this->createMock(UserInterface::class);
|
|
$user->method('getRoles')->willReturn([Role::PROF->value]);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($user);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsViewToAdmin(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesEditToAdmin(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsViewToSuperAdmin(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::SUPER_ADMIN->value);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsViewToAssignedTeacher(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
$this->setTeacherAffecte($teacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsEditToAssignedTeacher(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
$this->setTeacherAffecte($teacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesEditToUnassignedTeacher(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
// No assignment set
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsViewToEvaluationOwnerWithoutAssignment(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
// Teacher owns the evaluation but is no longer assigned
|
|
|
|
$evaluation = $this->createEvaluation(teacherId: $teacherId);
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesEditToEvaluationOwnerWithoutAssignment(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
// Teacher owns the evaluation but is no longer assigned
|
|
|
|
$evaluation = $this->createEvaluation(teacherId: $teacherId);
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsViewToActiveReplacement(): void
|
|
{
|
|
$replacementTeacherId = UserId::generate();
|
|
$this->createActiveReplacement($replacementTeacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itGrantsEditToActiveReplacement(): void
|
|
{
|
|
$replacementTeacherId = UserId::generate();
|
|
$this->createActiveReplacement($replacementTeacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesEditToExpiredReplacement(): void
|
|
{
|
|
$replacementTeacherId = UserId::generate();
|
|
$this->createExpiredReplacement($replacementTeacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesViewToExpiredReplacementWhoIsNotOwner(): void
|
|
{
|
|
$replacementTeacherId = UserId::generate();
|
|
$this->createExpiredReplacement($replacementTeacherId);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesViewToReplacementOnDifferentClassSubject(): void
|
|
{
|
|
$replacementTeacherId = UserId::generate();
|
|
|
|
// Remplacement actif mais sur une AUTRE classe/matière
|
|
$otherClassId = ClassId::generate();
|
|
$otherSubjectId = SubjectId::generate();
|
|
$replacement = TeacherReplacement::designer(
|
|
tenantId: $this->tenantId,
|
|
replacedTeacherId: UserId::generate(),
|
|
replacementTeacherId: $replacementTeacherId,
|
|
startDate: $this->now->modify('-1 day'),
|
|
endDate: $this->now->modify('+7 days'),
|
|
classes: [new ClassSubjectPair($otherClassId, $otherSubjectId)],
|
|
reason: 'Maladie',
|
|
createdBy: UserId::generate(),
|
|
now: $this->now->modify('-1 day'),
|
|
);
|
|
$this->replacementRepository->save($replacement);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId);
|
|
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesViewToNonTeacherNonAdminRoles(): void
|
|
{
|
|
$evaluation = $this->createEvaluation();
|
|
|
|
foreach ([Role::ELEVE->value, Role::PARENT->value, Role::SECRETARIAT->value, Role::VIE_SCOLAIRE->value] as $role) {
|
|
$token = $this->tokenWithSecurityUser($role);
|
|
$result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
self::assertSame(Voter::ACCESS_DENIED, $result, "Role {$role} should be denied VIEW");
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function itDeniesWhenNoTenantIsSet(): void
|
|
{
|
|
$teacherId = UserId::generate();
|
|
$this->setTeacherAffecte($teacherId);
|
|
|
|
$tenantContext = new TenantContext();
|
|
$clock = $this->createMock(Clock::class);
|
|
$clock->method('now')->willReturn($this->now);
|
|
|
|
$test = $this;
|
|
$affectationChecker = new class($test) implements EnseignantAffectationChecker {
|
|
public function __construct(private readonly GradeVoterTest $test)
|
|
{
|
|
}
|
|
|
|
public function estAffecte(
|
|
UserId $teacherId,
|
|
ClassId $classId,
|
|
SubjectId $subjectId,
|
|
TenantId $tenantId,
|
|
): bool {
|
|
return $this->test->getAffectationResult((string) $teacherId);
|
|
}
|
|
};
|
|
|
|
$autorisationChecker = new AutorisationSaisieNotesChecker(
|
|
$affectationChecker,
|
|
$this->replacementRepository,
|
|
);
|
|
|
|
$voter = new GradeVoter(
|
|
$autorisationChecker,
|
|
$tenantContext,
|
|
$clock,
|
|
);
|
|
|
|
$evaluation = $this->createEvaluation();
|
|
$token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId);
|
|
|
|
$result = $voter->vote($token, $evaluation, [GradeVoter::VIEW]);
|
|
|
|
self::assertSame(Voter::ACCESS_DENIED, $result);
|
|
}
|
|
|
|
private function createEvaluation(?UserId $teacherId = null): Evaluation
|
|
{
|
|
return Evaluation::creer(
|
|
tenantId: $this->tenantId,
|
|
classId: $this->classId,
|
|
subjectId: $this->subjectId,
|
|
teacherId: $teacherId ?? UserId::generate(),
|
|
title: 'Contrôle de maths',
|
|
description: null,
|
|
evaluationDate: $this->now,
|
|
gradeScale: new GradeScale(20),
|
|
coefficient: new Coefficient(1.0),
|
|
now: $this->now,
|
|
);
|
|
}
|
|
|
|
private function createActiveReplacement(UserId $replacementTeacherId): void
|
|
{
|
|
$replacement = TeacherReplacement::designer(
|
|
tenantId: $this->tenantId,
|
|
replacedTeacherId: UserId::generate(),
|
|
replacementTeacherId: $replacementTeacherId,
|
|
startDate: $this->now->modify('-1 day'),
|
|
endDate: $this->now->modify('+7 days'),
|
|
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
|
|
reason: 'Maladie',
|
|
createdBy: UserId::generate(),
|
|
now: $this->now->modify('-1 day'),
|
|
);
|
|
$this->replacementRepository->save($replacement);
|
|
}
|
|
|
|
private function createExpiredReplacement(UserId $replacementTeacherId): void
|
|
{
|
|
$replacement = TeacherReplacement::designer(
|
|
tenantId: $this->tenantId,
|
|
replacedTeacherId: UserId::generate(),
|
|
replacementTeacherId: $replacementTeacherId,
|
|
startDate: $this->now->modify('-14 days'),
|
|
endDate: $this->now->modify('-1 day'),
|
|
classes: [new ClassSubjectPair($this->classId, $this->subjectId)],
|
|
reason: 'Maladie',
|
|
createdBy: UserId::generate(),
|
|
now: $this->now->modify('-14 days'),
|
|
);
|
|
$this->replacementRepository->save($replacement);
|
|
}
|
|
|
|
private function tokenWithSecurityUser(string $role, ?UserId $userId = null): TokenInterface
|
|
{
|
|
$securityUser = new SecurityUser(
|
|
userId: $userId ?? UserId::generate(),
|
|
email: 'test@example.com',
|
|
hashedPassword: 'hashed',
|
|
tenantId: $this->tenantId,
|
|
roles: [$role],
|
|
);
|
|
|
|
$token = $this->createMock(TokenInterface::class);
|
|
$token->method('getUser')->willReturn($securityUser);
|
|
|
|
return $token;
|
|
}
|
|
}
|