Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Security/DatabaseUserProviderTest.php
Mathias STRASSER 4005c70082 feat: Gestion des utilisateurs (invitation, blocage, déblocage)
Permet aux administrateurs d'un établissement de gérer le cycle de vie
des comptes utilisateurs : inviter de nouveaux membres, bloquer/débloquer
des comptes actifs, et renvoyer des invitations en attente.

Chaque mutation vérifie l'appartenance au tenant courant pour empêcher
les accès cross-tenant. Le blocage est restreint aux comptes actifs
uniquement et un administrateur ne peut pas bloquer son propre compte.

Les comptes suspendus reçoivent une erreur 403 spécifique au login
(sans déclencher l'escalade du rate limiting) et les tentatives sont
tracées dans les métriques Prometheus.
2026-02-07 16:47:22 +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'),
role: 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,
);
}
}