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
240 lines
8.5 KiB
PHP
240 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Cache;
|
|
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\User;
|
|
use App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository;
|
|
use App\Shared\Infrastructure\Tenant\TenantId;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Cache\CacheItemInterface;
|
|
use Psr\Cache\CacheItemPoolInterface;
|
|
|
|
/**
|
|
* Tests for CacheUserRepository.
|
|
*
|
|
* 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
|
|
{
|
|
// Arrange: Create a mock cache that tracks expiration settings
|
|
$expirationSet = null;
|
|
|
|
$cacheItem = $this->createMock(CacheItemInterface::class);
|
|
$cacheItem->method('set')->willReturnSelf();
|
|
$cacheItem->method('expiresAfter')
|
|
->willReturnCallback(static function ($ttl) use (&$expirationSet, $cacheItem) {
|
|
$expirationSet = $ttl;
|
|
|
|
return $cacheItem;
|
|
});
|
|
|
|
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
|
$cachePool->method('getItem')->willReturn($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: No expiration should be set (expiresAfter should not be called with a TTL)
|
|
// The users.cache pool is configured with default_lifetime: 0 (no expiration)
|
|
// But CacheUserRepository should NOT explicitly set any TTL
|
|
self::assertNull(
|
|
$expirationSet,
|
|
'User cache entries should not have explicit expiration set by the repository'
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function userCanBeRetrievedById(): void
|
|
{
|
|
// Arrange
|
|
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
|
$email = 'test@example.com';
|
|
|
|
$userData = [
|
|
'id' => $userId,
|
|
'email' => $email,
|
|
'role' => 'ROLE_PARENT',
|
|
'tenant_id' => self::TENANT_ALPHA_ID,
|
|
'school_name' => 'École Test',
|
|
'statut' => 'pending',
|
|
'hashed_password' => null,
|
|
'date_naissance' => null,
|
|
'created_at' => '2026-01-15T10:00:00+00:00',
|
|
'activated_at' => null,
|
|
'consentement_parental' => null,
|
|
];
|
|
|
|
$cacheItem = $this->createMock(CacheItemInterface::class);
|
|
$cacheItem->method('isHit')->willReturn(true);
|
|
$cacheItem->method('get')->willReturn($userData);
|
|
|
|
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
|
$cachePool->method('getItem')->willReturn($cacheItem);
|
|
|
|
$repository = new CacheUserRepository($cachePool);
|
|
|
|
// Act
|
|
$user = $repository->findById(\App\Administration\Domain\Model\User\UserId::fromString($userId));
|
|
|
|
// Assert
|
|
self::assertNotNull($user);
|
|
self::assertSame($userId, (string) $user->id);
|
|
self::assertSame($email, (string) $user->email);
|
|
self::assertSame(Role::PARENT, $user->role);
|
|
}
|
|
|
|
#[Test]
|
|
public function userCanBeRetrievedByEmailWithinSameTenant(): void
|
|
{
|
|
// Arrange
|
|
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
|
$email = 'test@example.com';
|
|
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
|
|
|
|
$userData = [
|
|
'id' => $userId,
|
|
'email' => $email,
|
|
'role' => 'ROLE_PARENT',
|
|
'tenant_id' => self::TENANT_ALPHA_ID,
|
|
'school_name' => 'École Test',
|
|
'statut' => 'pending',
|
|
'hashed_password' => null,
|
|
'date_naissance' => null,
|
|
'created_at' => '2026-01-15T10:00:00+00:00',
|
|
'activated_at' => null,
|
|
'consentement_parental' => null,
|
|
];
|
|
|
|
$emailIndexItem = $this->createMock(CacheItemInterface::class);
|
|
$emailIndexItem->method('isHit')->willReturn(true);
|
|
$emailIndexItem->method('get')->willReturn($userId);
|
|
|
|
$userItem = $this->createMock(CacheItemInterface::class);
|
|
$userItem->method('isHit')->willReturn(true);
|
|
$userItem->method('get')->willReturn($userData);
|
|
|
|
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
|
$cachePool->method('getItem')
|
|
->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;
|
|
}
|
|
|
|
return $userItem;
|
|
});
|
|
|
|
$repository = new CacheUserRepository($cachePool);
|
|
|
|
// Act
|
|
$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'
|
|
);
|
|
}
|
|
}
|