Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php
Mathias STRASSER e930c505df feat: Attribution de rôles multiples par utilisateur
Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.

Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
2026-02-10 11:46:55 +01:00

241 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Security\DatabaseUserProvider;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\SecurityUserFactory;
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
use App\Shared\Infrastructure\Tenant\TenantConfig;
use App\Shared\Infrastructure\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
final class DatabaseUserProviderTest extends TestCase
{
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string TENANT_BETA_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
#[Test]
public function loadUserByIdentifierReturnsSecurityUserForActiveAccount(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->with(
self::callback(static fn (Email $email) => (string) $email === 'user@example.com'),
self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID)
)
->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$securityUser = $provider->loadUserByIdentifier('user@example.com');
self::assertInstanceOf(SecurityUser::class, $securityUser);
self::assertSame((string) $domainUser->email, $securityUser->getUserIdentifier());
self::assertSame((string) $domainUser->id, $securityUser->userId());
}
#[Test]
public function loadUserByIdentifierThrowsForNonExistentUser(): void
{
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')->willReturn(null);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('nonexistent@example.com');
}
#[Test]
public function loadUserByIdentifierThrowsForInactiveAccount(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::EN_ATTENTE);
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
// Should throw because account is not active (AC2: no account existence revelation)
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function loadUserByIdentifierThrowsAccountStatusExceptionForSuspendedUser(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::SUSPENDU, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$this->expectException(CustomUserMessageAccountStatusException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function loadUserByIdentifierThrowsForUnknownTenant(): void
{
$repository = $this->createMock(UserRepository::class);
// Repository should not even be called if tenant is unknown
$repository->expects(self::never())->method('findByEmail');
$provider = $this->createProvider($repository, 'unknown-tenant.classeo.local');
// Should throw generic error (don't reveal tenant doesn't exist)
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function loadUserByIdentifierUsesCorrectTenantFromRequest(): void
{
$tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID);
$tenantBetaId = TenantId::fromString(self::TENANT_BETA_ID);
// User exists in Alpha but not in Beta
$domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->willReturnCallback(static function (Email $email, TenantId $tenantId) use ($domainUser, $tenantAlphaId) {
// Only return user if looking in Alpha tenant
if ((string) $tenantId === (string) $tenantAlphaId) {
return $domainUser;
}
return null;
});
// Request comes from Beta tenant
$provider = $this->createProvider($repository, 'ecole-beta.classeo.local');
// Should throw because user doesn't exist in Beta tenant
$this->expectException(UserNotFoundException::class);
$provider->loadUserByIdentifier('user@example.com');
}
#[Test]
public function refreshUserReloadsUserFromRepository(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->expects(self::once())
->method('findByEmail')
->willReturn($domainUser);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
$factory = new SecurityUserFactory();
$existingSecurityUser = $factory->fromDomainUser($domainUser);
$refreshedUser = $provider->refreshUser($existingSecurityUser);
self::assertInstanceOf(SecurityUser::class, $refreshedUser);
}
#[Test]
public function supportsClassReturnsTrueForSecurityUser(): void
{
$repository = $this->createMock(UserRepository::class);
$provider = $this->createProvider($repository, 'ecole-alpha.classeo.local');
self::assertTrue($provider->supportsClass(SecurityUser::class));
self::assertFalse($provider->supportsClass(stdClass::class));
}
#[Test]
public function localhostFallsBackToEcoleAlphaTenant(): void
{
$tenantAlphaId = TenantId::fromString(self::TENANT_ALPHA_ID);
$domainUser = $this->createUser($tenantAlphaId, StatutCompte::ACTIF, hashedPassword: '$argon2id$hash');
$repository = $this->createMock(UserRepository::class);
$repository->method('findByEmail')
->with(
self::callback(static fn (Email $email) => (string) $email === 'user@example.com'),
// Should use ecole-alpha tenant ID when accessed from localhost
self::callback(static fn (TenantId $id) => (string) $id === self::TENANT_ALPHA_ID)
)
->willReturn($domainUser);
// Request from localhost should use ecole-alpha tenant
$provider = $this->createProvider($repository, 'localhost');
$securityUser = $provider->loadUserByIdentifier('user@example.com');
self::assertInstanceOf(SecurityUser::class, $securityUser);
}
private function createProvider(UserRepository $repository, string $host): DatabaseUserProvider
{
$tenantRegistry = new InMemoryTenantRegistry([
new TenantConfig(
TenantId::fromString(self::TENANT_ALPHA_ID),
'ecole-alpha',
'postgresql://localhost/alpha'
),
new TenantConfig(
TenantId::fromString(self::TENANT_BETA_ID),
'ecole-beta',
'postgresql://localhost/beta'
),
]);
$tenantResolver = new TenantResolver($tenantRegistry, 'classeo.local');
$request = Request::create('https://' . $host . '/api/login');
$requestStack = new RequestStack();
$requestStack->push($request);
return new DatabaseUserProvider($repository, $tenantResolver, $requestStack, new SecurityUserFactory());
}
private function createUser(TenantId $tenantId, StatutCompte $statut, ?string $hashedPassword = null): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email('user@example.com'),
roles: [Role::PARENT],
tenantId: $tenantId,
schoolName: 'École Test',
statut: $statut,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
hashedPassword: $hashedPassword,
activatedAt: $statut === StatutCompte::ACTIF ? new DateTimeImmutable() : null,
consentementParental: null,
);
}
}