Files
Classeo/backend/tests/Unit/Shared/Infrastructure/RateLimit/LoginRateLimitResultTest.php
Mathias STRASSER b9d9f48305 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
2026-02-01 14:43:12 +01:00

144 lines
4.7 KiB
PHP

<?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());
}
}