feat: Connexion utilisateur avec sécurité renforcée
Implémente la Story 1.4 du système d'authentification avec plusieurs couches de protection contre les attaques par force brute. Sécurité backend : - Authentification JWT avec access token (15min) + refresh token (7j) - Rotation automatique des refresh tokens avec détection de replay - Rate limiting progressif par IP (délai Fibonacci après échecs) - Intégration Cloudflare Turnstile CAPTCHA après 5 tentatives - Alerte email à l'utilisateur après blocage temporaire - Isolation multi-tenant (un utilisateur ne peut se connecter que sur son établissement) Frontend : - Page de connexion avec feedback visuel des délais et erreurs - Composant TurnstileCaptcha réutilisable - Gestion d'état auth avec stockage sécurisé des tokens - Tests E2E Playwright pour login, tenant isolation, et activation Infrastructure : - Configuration Symfony Security avec json_login + jwt - Cache pools séparés (filesystem en test, Redis en prod) - NullLoginRateLimiter pour environnement de test (évite blocage CI) - Génération des clés JWT en CI après démarrage du backend
This commit is contained in:
@@ -14,7 +14,7 @@ use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Service\RefreshTokenManager;
|
||||
use App\Administration\Domain\Exception\TokenAlreadyRotatedException;
|
||||
use App\Administration\Domain\Exception\TokenReplayDetectedException;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshTokenId;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RefreshTokenManagerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private RefreshTokenRepository $repository;
|
||||
private Clock $clock;
|
||||
private RefreshTokenManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(RefreshTokenRepository::class);
|
||||
$this->clock = new class implements Clock {
|
||||
public DateTimeImmutable $now;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->manager = new RefreshTokenManager($this->repository, $this->clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createGeneratesAndSavesNewToken(): void
|
||||
{
|
||||
$userId = UserId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
||||
|
||||
$this->repository->expects(self::once())
|
||||
->method('save')
|
||||
->with(self::isInstanceOf(RefreshToken::class));
|
||||
|
||||
$token = $this->manager->create($userId, $tenantId, $fingerprint);
|
||||
|
||||
self::assertSame($userId, $token->userId);
|
||||
self::assertSame($tenantId, $token->tenantId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshThrowsForTokenNotFound(): void
|
||||
{
|
||||
$this->repository->method('find')->willReturn(null);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Token not found');
|
||||
|
||||
// Use a valid UUID format for the token ID
|
||||
$validUuid = '550e8400-e29b-41d4-a716-446655440099';
|
||||
$this->manager->refresh(
|
||||
base64_encode($validUuid),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshRotatesTokenAndReturnsNew(): void
|
||||
{
|
||||
$existingToken = $this->createExistingToken(isRotated: false);
|
||||
$tokenString = $existingToken->toTokenString();
|
||||
$fingerprint = $existingToken->deviceFingerprint;
|
||||
|
||||
$this->repository->method('find')
|
||||
->willReturn($existingToken);
|
||||
|
||||
$this->repository->expects(self::exactly(2))
|
||||
->method('save');
|
||||
|
||||
$newToken = $this->manager->refresh($tokenString, $fingerprint);
|
||||
|
||||
self::assertNotEquals($existingToken->id, $newToken->id);
|
||||
self::assertEquals($existingToken->familyId, $newToken->familyId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshThrowsForExpiredToken(): void
|
||||
{
|
||||
$expiredToken = RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
new DateTimeImmutable('2026-01-01 10:00:00'), // Issued long ago
|
||||
3600, // 1 hour TTL - expired
|
||||
);
|
||||
|
||||
$this->repository->method('find')->willReturn($expiredToken);
|
||||
$this->repository->expects(self::once())->method('delete');
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('expired');
|
||||
|
||||
$this->manager->refresh(
|
||||
$expiredToken->toTokenString(),
|
||||
$expiredToken->deviceFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshThrowsAndInvalidatesFamilyForWrongDevice(): void
|
||||
{
|
||||
$existingToken = $this->createExistingToken();
|
||||
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
|
||||
|
||||
$this->repository->method('find')->willReturn($existingToken);
|
||||
$this->repository->expects(self::once())
|
||||
->method('invalidateFamily')
|
||||
->with($existingToken->familyId);
|
||||
|
||||
$this->expectException(TokenReplayDetectedException::class);
|
||||
|
||||
$this->manager->refresh($existingToken->toTokenString(), $differentFingerprint);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshThrowsAndInvalidatesFamilyForReplayAttack(): void
|
||||
{
|
||||
// Token rotaté il y a plus de 30 secondes (hors grace period)
|
||||
$rotatedToken = $this->createExistingToken(
|
||||
isRotated: true,
|
||||
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
|
||||
rotatedAt: new DateTimeImmutable('2026-01-31 09:30:00'), // Rotaté 30 min avant "now"
|
||||
);
|
||||
|
||||
$this->repository->method('find')->willReturn($rotatedToken);
|
||||
$this->repository->expects(self::once())
|
||||
->method('invalidateFamily')
|
||||
->with($rotatedToken->familyId);
|
||||
|
||||
$this->expectException(TokenReplayDetectedException::class);
|
||||
|
||||
$this->manager->refresh(
|
||||
$rotatedToken->toTokenString(),
|
||||
$rotatedToken->deviceFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshThrowsTokenAlreadyRotatedForGracePeriod(): void
|
||||
{
|
||||
// Token rotaté il y a 10 secondes (dans la grace period de 30s)
|
||||
$rotatedToken = $this->createExistingToken(
|
||||
isRotated: true,
|
||||
issuedAt: new DateTimeImmutable('2026-01-31 09:00:00'),
|
||||
rotatedAt: new DateTimeImmutable('2026-01-31 09:59:50'), // Rotaté 10s avant "now" (10:00:00)
|
||||
);
|
||||
|
||||
$this->repository->method('find')->willReturn($rotatedToken);
|
||||
|
||||
// Ne doit PAS invalider la famille
|
||||
$this->repository->expects(self::never())->method('invalidateFamily');
|
||||
|
||||
$this->expectException(TokenAlreadyRotatedException::class);
|
||||
|
||||
$this->manager->refresh(
|
||||
$rotatedToken->toTokenString(),
|
||||
$rotatedToken->deviceFingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function revokeInvalidatesTokenFamily(): void
|
||||
{
|
||||
$existingToken = $this->createExistingToken();
|
||||
|
||||
$this->repository->method('find')->willReturn($existingToken);
|
||||
$this->repository->expects(self::once())
|
||||
->method('invalidateFamily')
|
||||
->with($existingToken->familyId);
|
||||
|
||||
$this->manager->revoke($existingToken->toTokenString());
|
||||
}
|
||||
|
||||
private function createExistingToken(
|
||||
bool $isRotated = false,
|
||||
?DateTimeImmutable $issuedAt = null,
|
||||
?DateTimeImmutable $rotatedAt = null,
|
||||
): RefreshToken {
|
||||
$issuedAt ??= new DateTimeImmutable('2026-01-31 09:00:00');
|
||||
|
||||
return RefreshToken::reconstitute(
|
||||
id: RefreshTokenId::generate(),
|
||||
familyId: TokenFamilyId::generate(),
|
||||
userId: UserId::generate(),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
issuedAt: $issuedAt,
|
||||
expiresAt: $issuedAt->modify('+7 days'),
|
||||
rotatedFrom: null,
|
||||
isRotated: $isRotated,
|
||||
rotatedAt: $rotatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\RefreshToken;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RefreshTokenTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
#[Test]
|
||||
public function createGeneratesTokenWithCorrectData(): void
|
||||
{
|
||||
$userId = UserId::generate();
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
||||
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
|
||||
$token = RefreshToken::create($userId, $tenantId, $fingerprint, $issuedAt);
|
||||
|
||||
self::assertSame($userId, $token->userId);
|
||||
self::assertSame($tenantId, $token->tenantId);
|
||||
self::assertTrue($token->deviceFingerprint->equals($fingerprint));
|
||||
self::assertEquals($issuedAt, $token->issuedAt);
|
||||
self::assertNull($token->rotatedFrom);
|
||||
self::assertFalse($token->isRotated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createSetsExpirationBasedOnTtl(): void
|
||||
{
|
||||
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
$ttl = 86400; // 1 day
|
||||
|
||||
$token = RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
$issuedAt,
|
||||
$ttl,
|
||||
);
|
||||
|
||||
$expectedExpiry = $issuedAt->modify('+86400 seconds');
|
||||
self::assertEquals($expectedExpiry, $token->expiresAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rotateCreatesNewTokenWithSameFamily(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
|
||||
|
||||
[$newToken, $oldToken] = $token->rotate($rotateAt);
|
||||
|
||||
// Nouveau token
|
||||
self::assertNotSame($token->id, $newToken->id);
|
||||
self::assertSame($token->familyId, $newToken->familyId);
|
||||
self::assertSame($token->userId, $newToken->userId);
|
||||
self::assertSame($token->id, $newToken->rotatedFrom);
|
||||
self::assertFalse($newToken->isRotated);
|
||||
|
||||
// Ancien token marqué comme rotaté
|
||||
self::assertSame($token->id, $oldToken->id);
|
||||
self::assertTrue($oldToken->isRotated);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsTrueWhenPastExpiration(): void
|
||||
{
|
||||
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
$token = RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
$issuedAt,
|
||||
3600, // 1 hour
|
||||
);
|
||||
|
||||
self::assertFalse($token->isExpired(new DateTimeImmutable('2026-01-31 10:30:00')));
|
||||
self::assertTrue($token->isExpired(new DateTimeImmutable('2026-01-31 11:30:00')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isInGracePeriodReturnsTrueWithin30SecondsOfRotation(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$rotateAt = new DateTimeImmutable('2026-01-31 11:00:00');
|
||||
|
||||
[$_, $oldToken] = $token->rotate($rotateAt);
|
||||
|
||||
// Dans la grace period (30s après rotation)
|
||||
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:15')));
|
||||
self::assertTrue($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:30')));
|
||||
|
||||
// Après la grace period
|
||||
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:00:31')));
|
||||
self::assertFalse($oldToken->isInGracePeriod(new DateTimeImmutable('2026-01-31 11:01:00')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rotatePreservesOriginalTtl(): void
|
||||
{
|
||||
$issuedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
$originalTtl = 86400; // 1 day (web session)
|
||||
|
||||
$token = RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
$issuedAt,
|
||||
$originalTtl,
|
||||
);
|
||||
|
||||
$rotateAt = new DateTimeImmutable('2026-01-31 14:00:00');
|
||||
[$newToken, $oldToken] = $token->rotate($rotateAt);
|
||||
|
||||
// Le nouveau token doit avoir le même TTL que l'original
|
||||
$expectedExpiry = $rotateAt->modify("+{$originalTtl} seconds");
|
||||
self::assertEquals($expectedExpiry, $newToken->expiresAt);
|
||||
|
||||
// L'ancien token garde son expiration originale
|
||||
self::assertEquals($issuedAt->modify("+{$originalTtl} seconds"), $oldToken->expiresAt);
|
||||
|
||||
// L'ancien token a rotatedAt défini
|
||||
self::assertEquals($rotateAt, $oldToken->rotatedAt);
|
||||
self::assertNull($newToken->rotatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function matchesDeviceReturnsTrueForSameFingerprint(): void
|
||||
{
|
||||
$fingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
||||
$token = RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
$fingerprint,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$sameFingerprint = DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1');
|
||||
$differentFingerprint = DeviceFingerprint::fromRequest('Chrome/110', '10.0.0.1');
|
||||
|
||||
self::assertTrue($token->matchesDevice($sameFingerprint));
|
||||
self::assertFalse($token->matchesDevice($differentFingerprint));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toTokenStringAndExtractIdRoundTrips(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$tokenString = $token->toTokenString();
|
||||
$extractedId = RefreshToken::extractIdFromTokenString($tokenString);
|
||||
|
||||
self::assertEquals($token->id, $extractedId);
|
||||
}
|
||||
|
||||
private function createToken(): RefreshToken
|
||||
{
|
||||
return RefreshToken::create(
|
||||
UserId::generate(),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
new DateTimeImmutable('2026-01-31 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -163,7 +163,7 @@ final class ActivateAccountProcessorTest extends TestCase
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
|
||||
public function findByEmail(\App\Administration\Domain\Model\User\Email $email, TenantId $tenantId): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ use Psr\Cache\CacheItemPoolInterface;
|
||||
/**
|
||||
* Tests for CacheUserRepository.
|
||||
*
|
||||
* Key invariant: Users must not expire from cache (unlike activation tokens which have 7-day TTL).
|
||||
* This was a bug where users were stored in the activation_tokens.cache pool with TTL,
|
||||
* causing activated accounts to become inaccessible after 7 days.
|
||||
* Key invariants:
|
||||
* - Users must not expire from cache (unlike activation tokens which have 7-day TTL)
|
||||
* - Email lookups are scoped by tenant ID for multi-tenant isolation
|
||||
*/
|
||||
final class CacheUserRepositoryTest 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 userIsSavedWithoutExpiration(): void
|
||||
{
|
||||
@@ -48,7 +51,7 @@ final class CacheUserRepositoryTest extends TestCase
|
||||
$user = User::creer(
|
||||
email: new Email('test@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
@@ -72,13 +75,12 @@ final class CacheUserRepositoryTest extends TestCase
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => self::TENANT_ALPHA_ID,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
@@ -108,18 +110,18 @@ final class CacheUserRepositoryTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCanBeRetrievedByEmail(): void
|
||||
public function userCanBeRetrievedByEmailWithinSameTenant(): void
|
||||
{
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'tenant_id' => self::TENANT_ALPHA_ID,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
@@ -139,8 +141,10 @@ final class CacheUserRepositoryTest extends TestCase
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')
|
||||
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) {
|
||||
if (str_starts_with($key, 'user_email:')) {
|
||||
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem, $tenantId) {
|
||||
// Email index key should include tenant ID
|
||||
$expectedEmailKey = 'user_email:' . $tenantId . ':test_at_example_dot_com';
|
||||
if ($key === $expectedEmailKey) {
|
||||
return $emailIndexItem;
|
||||
}
|
||||
|
||||
@@ -150,11 +154,86 @@ final class CacheUserRepositoryTest extends TestCase
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act
|
||||
$user = $repository->findByEmail(new Email($email));
|
||||
$user = $repository->findByEmail(new Email($email), $tenantId);
|
||||
|
||||
// Assert
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame($email, (string) $user->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCannotBeFoundByEmailInDifferentTenant(): void
|
||||
{
|
||||
// Arrange: User exists in tenant Alpha
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
$tenantBeta = TenantId::fromString(self::TENANT_BETA_ID);
|
||||
$email = new Email('test@example.com');
|
||||
|
||||
// Cache miss for tenant Beta's email index
|
||||
$missItem = $this->createMock(CacheItemInterface::class);
|
||||
$missItem->method('isHit')->willReturn(false);
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')
|
||||
->willReturnCallback(static function ($key) use ($missItem, $tenantBeta) {
|
||||
// When looking up with tenant Beta, return cache miss
|
||||
$betaEmailKey = 'user_email:' . $tenantBeta . ':test_at_example_dot_com';
|
||||
if ($key === $betaEmailKey) {
|
||||
return $missItem;
|
||||
}
|
||||
|
||||
// For any other key, also return miss
|
||||
return $missItem;
|
||||
});
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act: Try to find user in tenant Beta (where they don't exist)
|
||||
$user = $repository->findByEmail($email, $tenantBeta);
|
||||
|
||||
// Assert: User should not be found
|
||||
self::assertNull($user, 'User from tenant Alpha should not be found when searching in tenant Beta');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emailIndexKeyIncludesTenantId(): void
|
||||
{
|
||||
// Arrange: Track what cache keys are used
|
||||
$savedKeys = [];
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('set')->willReturnSelf();
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')
|
||||
->willReturnCallback(static function ($key) use (&$savedKeys, $cacheItem) {
|
||||
$savedKeys[] = $key;
|
||||
|
||||
return $cacheItem;
|
||||
});
|
||||
$cachePool->method('save')->willReturn(true);
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
$user = User::creer(
|
||||
email: new Email('test@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
// Act
|
||||
$repository->save($user);
|
||||
|
||||
// Assert: Email index key should include tenant ID
|
||||
$emailIndexKey = 'user_email:' . self::TENANT_ALPHA_ID . ':test_at_example_dot_com';
|
||||
self::assertContains(
|
||||
$emailIndexKey,
|
||||
$savedKeys,
|
||||
'Email index cache key should include tenant ID for multi-tenant isolation'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<?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\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 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\JwtPayloadEnricher;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class JwtPayloadEnricherTest extends TestCase
|
||||
{
|
||||
private JwtPayloadEnricher $enricher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->enricher = new JwtPayloadEnricher();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function onJWTCreatedAddsCustomClaimsToPayload(): void
|
||||
{
|
||||
$userId = UserId::generate();
|
||||
$tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440002');
|
||||
|
||||
$securityUser = new SecurityUser(
|
||||
userId: $userId,
|
||||
email: 'user@example.com',
|
||||
hashedPassword: 'hashed',
|
||||
tenantId: $tenantId,
|
||||
roles: ['ROLE_PARENT'],
|
||||
);
|
||||
|
||||
$initialPayload = ['username' => 'user@example.com'];
|
||||
$event = new JWTCreatedEvent($initialPayload, $securityUser);
|
||||
|
||||
$this->enricher->onJWTCreated($event);
|
||||
|
||||
$payload = $event->getData();
|
||||
|
||||
self::assertSame((string) $userId, $payload['user_id']);
|
||||
self::assertSame((string) $tenantId, $payload['tenant_id']);
|
||||
self::assertSame(['ROLE_PARENT'], $payload['roles']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function onJWTCreatedPreservesExistingPayloadData(): void
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::generate(),
|
||||
email: 'user@example.com',
|
||||
hashedPassword: 'hashed',
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
roles: ['ROLE_ADMIN'],
|
||||
);
|
||||
|
||||
$initialPayload = [
|
||||
'username' => 'user@example.com',
|
||||
'iat' => 1706436600,
|
||||
'exp' => 1706438400,
|
||||
];
|
||||
$event = new JWTCreatedEvent($initialPayload, $securityUser);
|
||||
|
||||
$this->enricher->onJWTCreated($event);
|
||||
|
||||
$payload = $event->getData();
|
||||
|
||||
self::assertSame('user@example.com', $payload['username']);
|
||||
self::assertSame(1706436600, $payload['iat']);
|
||||
self::assertSame(1706438400, $payload['exp']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function onJWTCreatedDoesNothingForNonSecurityUser(): void
|
||||
{
|
||||
$nonSecurityUser = $this->createMock(\Symfony\Component\Security\Core\User\UserInterface::class);
|
||||
|
||||
$initialPayload = ['username' => 'other@example.com'];
|
||||
$event = new JWTCreatedEvent($initialPayload, $nonSecurityUser);
|
||||
|
||||
$this->enricher->onJWTCreated($event);
|
||||
|
||||
$payload = $event->getData();
|
||||
|
||||
// Payload should remain unchanged
|
||||
self::assertSame(['username' => 'other@example.com'], $payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?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\Infrastructure\Security\SecurityUserFactory;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SecurityUserTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private SecurityUserFactory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->factory = new SecurityUserFactory();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function factoryCreatesSecurityUserWithCorrectData(): void
|
||||
{
|
||||
$domainUser = $this->createActivatedUser(Role::PARENT);
|
||||
|
||||
$securityUser = $this->factory->fromDomainUser($domainUser);
|
||||
|
||||
self::assertSame((string) $domainUser->email, $securityUser->getUserIdentifier());
|
||||
self::assertSame((string) $domainUser->id, $securityUser->userId());
|
||||
self::assertSame((string) $domainUser->email, $securityUser->email());
|
||||
self::assertSame($domainUser->hashedPassword, $securityUser->getPassword());
|
||||
self::assertSame((string) $domainUser->tenantId, $securityUser->tenantId());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('roleProvider')]
|
||||
public function factoryMapsRolesToSymfonyRoles(Role $domainRole, string $expectedSymfonyRole): void
|
||||
{
|
||||
$domainUser = $this->createActivatedUser($domainRole);
|
||||
|
||||
$securityUser = $this->factory->fromDomainUser($domainUser);
|
||||
|
||||
self::assertContains($expectedSymfonyRole, $securityUser->getRoles());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{Role, string}>
|
||||
*/
|
||||
public static function roleProvider(): iterable
|
||||
{
|
||||
yield 'Super Admin' => [Role::SUPER_ADMIN, 'ROLE_SUPER_ADMIN'];
|
||||
yield 'Admin' => [Role::ADMIN, 'ROLE_ADMIN'];
|
||||
yield 'Prof' => [Role::PROF, 'ROLE_PROF'];
|
||||
yield 'Vie scolaire' => [Role::VIE_SCOLAIRE, 'ROLE_VIE_SCOLAIRE'];
|
||||
yield 'Secrétariat' => [Role::SECRETARIAT, 'ROLE_SECRETARIAT'];
|
||||
yield 'Parent' => [Role::PARENT, 'ROLE_PARENT'];
|
||||
yield 'Elève' => [Role::ELEVE, 'ROLE_ELEVE'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function eraseCredentialsDoesNothing(): void
|
||||
{
|
||||
$domainUser = $this->createActivatedUser(Role::PARENT);
|
||||
$securityUser = $this->factory->fromDomainUser($domainUser);
|
||||
$passwordBefore = $securityUser->getPassword();
|
||||
|
||||
$securityUser->eraseCredentials();
|
||||
|
||||
// Les credentials sont immutables, donc rien ne change
|
||||
self::assertSame($passwordBefore, $securityUser->getPassword());
|
||||
}
|
||||
|
||||
private function createActivatedUser(Role $role): User
|
||||
{
|
||||
return User::reconstitute(
|
||||
id: UserId::generate(),
|
||||
email: new Email('user@example.com'),
|
||||
role: $role,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
statut: StatutCompte::ACTIF,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
hashedPassword: '$argon2id$v=19$m=65536,t=4,p=1$salt$hash',
|
||||
activatedAt: new DateTimeImmutable('2026-01-15 12:00:00'),
|
||||
consentementParental: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Captcha;
|
||||
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileValidator;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
final class TurnstileValidatorTest extends TestCase
|
||||
{
|
||||
private const string SECRET_KEY = 'test-secret-key';
|
||||
|
||||
#[Test]
|
||||
public function validTokenReturnsValid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => true,
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('valid-token', '192.168.1.1');
|
||||
|
||||
self::assertTrue($result->isValid);
|
||||
self::assertNull($result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidTokenReturnsInvalid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => false,
|
||||
'error-codes' => ['invalid-input-response'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('invalid-token', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Token invalide ou expiré', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expiredTokenReturnsInvalid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => false,
|
||||
'error-codes' => ['timeout-or-duplicate'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('expired-token', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Token expiré ou déjà utilisé', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emptyTokenReturnsInvalid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient(); // No request should be made
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Token vide', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function apiErrorReturnsValidWhenFailOpenEnabled(): void
|
||||
{
|
||||
// Simulate API error with fail open
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('', ['http_code' => 500]),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: true);
|
||||
|
||||
$result = $validator->validate('some-token', '192.168.1.1');
|
||||
|
||||
// Fail open - allow through on API errors
|
||||
self::assertTrue($result->isValid);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function apiErrorReturnsInvalidWhenFailOpenDisabled(): void
|
||||
{
|
||||
// Simulate API error with fail closed (production default)
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('', ['http_code' => 500]),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: false);
|
||||
|
||||
$result = $validator->validate('some-token', '192.168.1.1');
|
||||
|
||||
// Fail closed - block on API errors
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Service de vérification temporairement indisponible', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function networkErrorReturnsValidWhenFailOpenEnabled(): void
|
||||
{
|
||||
// Simulate network error with fail open
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('', ['error' => 'Network error']),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: true);
|
||||
|
||||
$result = $validator->validate('some-token', '192.168.1.1');
|
||||
|
||||
// Fail open - allow through on network errors
|
||||
self::assertTrue($result->isValid);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function networkErrorReturnsInvalidWhenFailOpenDisabled(): void
|
||||
{
|
||||
// Simulate network error with fail closed
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('', ['error' => 'Network error']),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY, failOpen: false);
|
||||
|
||||
$result = $validator->validate('some-token', '192.168.1.1');
|
||||
|
||||
// Fail closed - block on network errors
|
||||
self::assertFalse($result->isValid);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidSecretKeyReturnsInvalid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => false,
|
||||
'error-codes' => ['invalid-input-secret'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('token', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Configuration serveur invalide', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function missingSecretKeyReturnsInvalid(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => false,
|
||||
'error-codes' => ['missing-input-secret'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('token', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Configuration serveur invalide', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknownErrorCodeReturnsGenericMessage(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => false,
|
||||
'error-codes' => ['unknown-error-code'],
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('token', '192.168.1.1');
|
||||
|
||||
self::assertFalse($result->isValid);
|
||||
self::assertSame('Vérification échouée', $result->errorMessage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validationWithoutIpWorks(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse(json_encode([
|
||||
'success' => true,
|
||||
])),
|
||||
]);
|
||||
|
||||
$validator = new TurnstileValidator($httpClient, new NullLogger(), self::SECRET_KEY);
|
||||
|
||||
$result = $validator->validate('valid-token');
|
||||
|
||||
self::assertTrue($result->isValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileResult;
|
||||
use App\Shared\Infrastructure\Captcha\TurnstileValidatorInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitListener;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class LoginRateLimitListenerTest extends TestCase
|
||||
{
|
||||
private function createListener(
|
||||
?LoginRateLimiterInterface $rateLimiter = null,
|
||||
?TurnstileValidatorInterface $turnstile = null,
|
||||
?CacheItemPoolInterface $cache = null,
|
||||
): LoginRateLimitListener {
|
||||
return new LoginRateLimitListener(
|
||||
$rateLimiter ?? $this->createMock(LoginRateLimiterInterface::class),
|
||||
$turnstile ?? $this->createMock(TurnstileValidatorInterface::class),
|
||||
$cache ?? $this->createCacheMock(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createCacheMock(int $captchaFailures = 0): CacheItemPoolInterface
|
||||
{
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn($captchaFailures > 0);
|
||||
$cacheItem->method('get')->willReturn($captchaFailures);
|
||||
$cacheItem->method('set')->willReturnSelf();
|
||||
$cacheItem->method('expiresAfter')->willReturnSelf();
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->method('getItem')->willReturn($cacheItem);
|
||||
$cache->method('save')->willReturn(true);
|
||||
$cache->method('deleteItem')->willReturn(true);
|
||||
|
||||
return $cache;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockedIpReturns429BeforeAuthentication(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::blocked(retryAfter: 600));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'blocked@example.com', 'password' => 'correct'])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/ip-blocked', $content['type']);
|
||||
self::assertSame(600, $content['retryAfter']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedEmailProceedsToAuthentication(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 2,
|
||||
delaySeconds: 1,
|
||||
requiresCaptcha: false,
|
||||
));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'user@example.com', 'password' => 'password'])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// No response set = request continues to authentication
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithoutTokenReturns428(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['email' => 'user@example.com', 'password' => 'password'])
|
||||
// No captcha_token
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_PRECONDITION_REQUIRED, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/captcha-required', $content['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithValidTokenProceeds(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::valid());
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'valid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// Should proceed to authentication
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaRequiredWithInvalidTokenReturns400(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/captcha-invalid', $content['type']);
|
||||
self::assertSame('Token invalide', $content['detail']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captchaFailuresPersistAcrossRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
// Simulate 2 previous failures (next failure = 3 = blocked)
|
||||
$cache = $this->createCacheMock(captchaFailures: 2);
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
$request->server->set('REMOTE_ADDR', '192.168.1.100');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// 3rd CAPTCHA failure should block the IP
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
|
||||
|
||||
$content = json_decode($event->getResponse()->getContent(), true);
|
||||
self::assertSame('/errors/ip-blocked', $content['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheIsSavedOnCaptchaFailure(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::invalid('Token invalide'));
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn(false);
|
||||
$cacheItem->method('get')->willReturn(0);
|
||||
$cacheItem->expects(self::once())->method('set')->with(1)->willReturnSelf();
|
||||
$cacheItem->expects(self::once())->method('expiresAfter')->with(900)->willReturnSelf();
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->method('getItem')->willReturn($cacheItem);
|
||||
$cache->expects(self::once())->method('save')->with($cacheItem)->willReturn(true);
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'invalid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cacheIsDeletedOnValidCaptcha(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->method('check')
|
||||
->willReturn(LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 8,
|
||||
requiresCaptcha: true,
|
||||
));
|
||||
|
||||
$turnstile = $this->createMock(TurnstileValidatorInterface::class);
|
||||
$turnstile->method('validate')
|
||||
->willReturn(TurnstileResult::valid());
|
||||
|
||||
$cache = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cache->expects(self::once())->method('deleteItem');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter, turnstile: $turnstile, cache: $cache);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password',
|
||||
'captcha_token' => 'valid-token',
|
||||
])
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignoresNonLoginRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create('/api/users', 'GET');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignoresLoginGetRequests(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create('/api/login', 'GET');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function proceedsIfEmailMissingFromRequest(): void
|
||||
{
|
||||
$rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$rateLimiter->expects(self::never())->method('check');
|
||||
|
||||
$listener = $this->createListener(rateLimiter: $rateLimiter);
|
||||
|
||||
$request = Request::create(
|
||||
'/api/login',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['password' => 'password']) // No email
|
||||
);
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener($event);
|
||||
|
||||
// Let the validator handle missing email
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LoginRateLimitResultTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[DataProvider('fibonacciDelayProvider')]
|
||||
public function fibonacciDelayCalculatesCorrectly(int $attempts, int $expectedDelay): void
|
||||
{
|
||||
self::assertSame($expectedDelay, LoginRateLimitResult::fibonacciDelay($attempts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{int, int}>
|
||||
*/
|
||||
public static function fibonacciDelayProvider(): iterable
|
||||
{
|
||||
yield '0 attempts = no delay' => [0, 0];
|
||||
yield '1 attempt = no delay' => [1, 0];
|
||||
yield '2 attempts = 1s' => [2, 1];
|
||||
yield '3 attempts = 1s' => [3, 1];
|
||||
yield '4 attempts = 2s' => [4, 2];
|
||||
yield '5 attempts = 3s' => [5, 3];
|
||||
yield '6 attempts = 5s' => [6, 5];
|
||||
yield '7 attempts = 8s' => [7, 8];
|
||||
yield '8 attempts = 13s' => [8, 13];
|
||||
yield '9 attempts = 21s' => [9, 21];
|
||||
yield '10 attempts = 34s' => [10, 34];
|
||||
yield '11 attempts = 55s' => [11, 55];
|
||||
yield '12 attempts = 89s (max)' => [12, 89];
|
||||
yield '20 attempts = 89s (capped)' => [20, 89];
|
||||
yield '100 attempts = 89s (capped)' => [100, 89];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedResultHasCorrectProperties(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 3,
|
||||
delaySeconds: 1,
|
||||
requiresCaptcha: false,
|
||||
);
|
||||
|
||||
self::assertTrue($result->isAllowed);
|
||||
self::assertSame(3, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
self::assertSame(1, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowedWithZeroDelayHasNullRetryAfter(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 1,
|
||||
delaySeconds: 0,
|
||||
requiresCaptcha: false,
|
||||
);
|
||||
|
||||
self::assertNull($result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockedResultHasCorrectProperties(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 900);
|
||||
|
||||
self::assertFalse($result->isAllowed);
|
||||
self::assertSame(0, $result->attempts);
|
||||
self::assertSame(900, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertSame(900, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toHeadersIncludesAllRelevantHeaders(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(
|
||||
attempts: 6,
|
||||
delaySeconds: 5,
|
||||
requiresCaptcha: true,
|
||||
);
|
||||
|
||||
$headers = $result->toHeaders();
|
||||
|
||||
self::assertSame('6', $headers['X-Login-Attempts']);
|
||||
self::assertSame('5', $headers['X-Login-Delay']);
|
||||
self::assertSame('5', $headers['Retry-After']);
|
||||
self::assertSame('true', $headers['X-Captcha-Required']);
|
||||
self::assertArrayNotHasKey('X-IP-Blocked', $headers);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toHeadersForBlockedIp(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 600);
|
||||
|
||||
$headers = $result->toHeaders();
|
||||
|
||||
self::assertSame('true', $headers['X-IP-Blocked']);
|
||||
self::assertSame('600', $headers['Retry-After']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayFormatsSeconds(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(attempts: 2, delaySeconds: 1, requiresCaptcha: false);
|
||||
self::assertSame('1 seconde', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::allowed(attempts: 6, delaySeconds: 5, requiresCaptcha: false);
|
||||
self::assertSame('5 secondes', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::allowed(attempts: 8, delaySeconds: 13, requiresCaptcha: false);
|
||||
self::assertSame('13 secondes', $result->getFormattedDelay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayFormatsMinutes(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 60);
|
||||
self::assertSame('1 minute', $result->getFormattedDelay());
|
||||
|
||||
$result = LoginRateLimitResult::blocked(retryAfter: 900);
|
||||
self::assertSame('15 minutes', $result->getFormattedDelay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getFormattedDelayReturnsEmptyForZero(): void
|
||||
{
|
||||
$result = LoginRateLimitResult::allowed(attempts: 1, delaySeconds: 0, requiresCaptcha: false);
|
||||
self::assertSame('', $result->getFormattedDelay());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\RateLimit;
|
||||
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiter;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final class LoginRateLimiterTest extends TestCase
|
||||
{
|
||||
private ArrayAdapter $cache;
|
||||
private LoginRateLimiter $rateLimiter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->cache = new ArrayAdapter();
|
||||
$this->rateLimiter = new LoginRateLimiter($this->cache);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkReturnsAllowedForFirstAttempt(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'test@example.com');
|
||||
|
||||
self::assertTrue($result->isAllowed);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
self::assertSame(0, $result->attempts);
|
||||
self::assertSame(0, $result->delaySeconds);
|
||||
self::assertFalse($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recordFailureIncrementsAttemptsAndCalculatesFibonacciDelay(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// First failure - no delay (1 attempt = 0s)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(1, $result->attempts);
|
||||
self::assertSame(0, $result->delaySeconds);
|
||||
|
||||
// Second failure - delay 1s (F0)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(2, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
|
||||
// Third failure - delay 1s (F1)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(3, $result->attempts);
|
||||
self::assertSame(1, $result->delaySeconds);
|
||||
|
||||
// Fourth failure - delay 2s (F2)
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(4, $result->attempts);
|
||||
self::assertSame(2, $result->delaySeconds);
|
||||
|
||||
// Fifth failure - delay 3s (F3), CAPTCHA required
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertSame(5, $result->attempts);
|
||||
self::assertSame(3, $result->delaySeconds);
|
||||
self::assertTrue($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkReturnsCorrectStateAfterFailures(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// Record 5 failures
|
||||
for ($i = 0; $i < 5; ++$i) {
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
}
|
||||
|
||||
// Check should return the current state
|
||||
$result = $this->rateLimiter->check($request, $email);
|
||||
self::assertSame(5, $result->attempts);
|
||||
self::assertTrue($result->requiresCaptcha);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blockIpPreventsSubsequentAttempts(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
$request = $this->createRequest($ip);
|
||||
|
||||
$this->rateLimiter->blockIp($ip);
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'any@email.com');
|
||||
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertFalse($result->isAllowed);
|
||||
self::assertGreaterThan(0, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function recordFailureBlocksIpAfter20Attempts(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'attacker@example.com';
|
||||
|
||||
// Record 19 failures - should not be blocked
|
||||
for ($i = 0; $i < 19; ++$i) {
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertFalse($result->ipBlocked);
|
||||
}
|
||||
|
||||
// 20th failure - should be blocked
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
self::assertTrue($result->ipBlocked);
|
||||
self::assertSame(LoginRateLimiterInterface::IP_BLOCK_DURATION, $result->retryAfter);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resetClearsAttemptsForEmail(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
$email = 'test@example.com';
|
||||
|
||||
// Record some failures
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
$this->rateLimiter->recordFailure($request, $email);
|
||||
|
||||
// Reset
|
||||
$this->rateLimiter->reset($email);
|
||||
|
||||
// Check should show 0 attempts
|
||||
$result = $this->rateLimiter->check($request, $email);
|
||||
self::assertSame(0, $result->attempts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isIpBlockedReturnsFalseForUnblockedIp(): void
|
||||
{
|
||||
self::assertFalse($this->rateLimiter->isIpBlocked('192.168.1.1'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isIpBlockedReturnsTrueForBlockedIp(): void
|
||||
{
|
||||
$ip = '192.168.1.1';
|
||||
$this->rateLimiter->blockIp($ip);
|
||||
|
||||
self::assertTrue($this->rateLimiter->isIpBlocked($ip));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function differentEmailsHaveSeparateAttemptCounters(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
// Record failures for email1
|
||||
$this->rateLimiter->recordFailure($request, 'email1@test.com');
|
||||
$this->rateLimiter->recordFailure($request, 'email1@test.com');
|
||||
|
||||
// Record failure for email2
|
||||
$this->rateLimiter->recordFailure($request, 'email2@test.com');
|
||||
|
||||
// Check each email
|
||||
$result1 = $this->rateLimiter->check($request, 'email1@test.com');
|
||||
$result2 = $this->rateLimiter->check($request, 'email2@test.com');
|
||||
|
||||
self::assertSame(2, $result1->attempts);
|
||||
self::assertSame(1, $result2->attempts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function emailNormalizationIsCaseInsensitive(): void
|
||||
{
|
||||
$request = $this->createRequest('192.168.1.1');
|
||||
|
||||
$this->rateLimiter->recordFailure($request, 'Test@Example.COM');
|
||||
$this->rateLimiter->recordFailure($request, 'test@example.com');
|
||||
|
||||
$result = $this->rateLimiter->check($request, 'TEST@EXAMPLE.COM');
|
||||
|
||||
self::assertSame(2, $result->attempts);
|
||||
}
|
||||
|
||||
private function createRequest(string $clientIp): Request
|
||||
{
|
||||
$request = Request::create('/api/login', 'POST');
|
||||
$request->server->set('REMOTE_ADDR', $clientIp);
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user