Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CacheUserRepositoryTest.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

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'
);
}
}