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.
241 lines
9.5 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|