feat: Gestion des sessions utilisateur

Permet aux utilisateurs de visualiser et gérer leurs sessions actives
sur différents appareils, avec la possibilité de révoquer des sessions
à distance en cas de suspicion d'activité non autorisée.

Fonctionnalités :
- Liste des sessions actives avec métadonnées (appareil, navigateur, localisation)
- Identification de la session courante
- Révocation individuelle d'une session
- Révocation de toutes les autres sessions
- Déconnexion avec nettoyage des cookies sur les deux chemins (legacy et actuel)

Sécurité :
- Cache frontend scopé par utilisateur pour éviter les fuites entre comptes
- Validation que le refresh token appartient à l'utilisateur JWT authentifié
- TTL des sessions Redis aligné sur l'expiration du refresh token
- Événements d'audit pour traçabilité (SessionInvalidee, ToutesSessionsInvalidees)

@see Story 1.6 - Gestion des sessions
This commit is contained in:
2026-02-03 10:10:40 +01:00
parent affad287f9
commit b823479658
40 changed files with 4222 additions and 42 deletions

View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Administration\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\Session\DeviceInfo;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\SessionRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for sessions endpoints.
*
* @see Story 1.6 - Gestion des sessions
*/
final class SessionsEndpointsTest extends ApiTestCase
{
/**
* Opt-in for API Platform 5.0 behavior where kernel boot is explicit.
*
* @see https://github.com/api-platform/core/issues/6971
*/
protected static ?bool $alwaysBootKernel = true;
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
// =========================================================================
// Logout endpoint tests
// =========================================================================
#[Test]
public function logoutEndpointAcceptsPostWithoutAuthentication(): void
{
$client = static::createClient();
// Logout should work even without a valid token (graceful logout)
$client->request('POST', '/api/token/logout', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
// Should return 200 OK (graceful logout even without token)
self::assertResponseStatusCodeSame(200);
}
#[Test]
public function logoutEndpointReturnsSuccessMessage(): void
{
$client = static::createClient();
$client->request('POST', '/api/token/logout', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
self::assertResponseIsSuccessful();
self::assertJsonContains(['message' => 'Logout successful']);
}
#[Test]
public function logoutEndpointDeletesRefreshTokenCookie(): void
{
$client = static::createClient();
$response = $client->request('POST', '/api/token/logout', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
// Check that Set-Cookie header is present (to delete the cookie)
$headers = $response->getHeaders(false);
self::assertArrayHasKey('set-cookie', $headers);
// The cookie should be set to empty with past expiration
$setCookieHeader = $headers['set-cookie'][0];
self::assertStringContainsString('refresh_token=', $setCookieHeader);
}
#[Test]
public function logoutEndpointDeletesRefreshTokenCookieOnApiAndLegacyPaths(): void
{
$client = static::createClient();
$response = $client->request('POST', '/api/token/logout', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
$headers = $response->getHeaders(false);
self::assertArrayHasKey('set-cookie', $headers);
// Should have 2 Set-Cookie headers for refresh_token deletion
$setCookieHeaders = $headers['set-cookie'];
self::assertGreaterThanOrEqual(2, count($setCookieHeaders), 'Expected at least 2 Set-Cookie headers');
// Collect all refresh_token cookie paths
$paths = [];
foreach ($setCookieHeaders as $header) {
if (stripos($header, 'refresh_token=') !== false) {
// Extract path with case-insensitive matching
if (preg_match('/path=([^;]+)/i', $header, $matches)) {
$paths[] = trim($matches[1]);
}
}
}
// Must clear both /api (current) and /api/token (legacy) paths
self::assertContains('/api', $paths, 'Missing Set-Cookie for Path=/api');
self::assertContains('/api/token', $paths, 'Missing Set-Cookie for Path=/api/token (legacy)');
}
// =========================================================================
// Sessions list endpoint tests - Security
// =========================================================================
/**
* Without a valid tenant subdomain, the endpoint returns 404.
* This is correct security behavior: don't reveal endpoint existence.
*/
#[Test]
public function listSessionsReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('GET', '/api/me/sessions', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
self::assertResponseStatusCodeSame(404);
self::assertJsonContains(['message' => 'Resource not found']);
}
// =========================================================================
// Revoke single session endpoint tests - Security
// =========================================================================
/**
* Without a valid tenant subdomain, the endpoint returns 404.
* This is correct security behavior: don't reveal endpoint existence.
*/
#[Test]
public function revokeSessionReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('DELETE', '/api/me/sessions/00000000-0000-0000-0000-000000000001', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
self::assertResponseStatusCodeSame(404);
self::assertJsonContains(['message' => 'Resource not found']);
}
// =========================================================================
// Revoke all sessions endpoint tests - Security
// =========================================================================
/**
* Without a valid tenant subdomain, the endpoint returns 404.
* This is correct security behavior: don't reveal endpoint existence.
*/
#[Test]
public function revokeAllSessionsReturns404WithoutTenant(): void
{
$client = static::createClient();
$client->request('DELETE', '/api/me/sessions', [
'headers' => [
'Host' => 'localhost',
'Accept' => 'application/json',
],
]);
// Tenant middleware intercepts and returns 404 (not revealing endpoint)
self::assertResponseStatusCodeSame(404);
self::assertJsonContains(['message' => 'Resource not found']);
}
// =========================================================================
// Authenticated session list tests
// =========================================================================
/**
* Test that listing sessions requires authentication.
* Returns 401 when no JWT token is provided.
*/
#[Test]
public function listSessionsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('GET', 'http://ecole-alpha.classeo.local/api/me/sessions', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Test that revoking sessions requires authentication.
* Returns 401 when no JWT token is provided.
*/
#[Test]
public function revokeSessionReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$familyId = TokenFamilyId::generate();
$client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions/' . $familyId, [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Test that revoking all sessions requires authentication.
* Returns 401 when no JWT token is provided.
*/
#[Test]
public function revokeAllSessionsReturns401WithoutAuthentication(): void
{
$client = static::createClient();
$client->request('DELETE', 'http://ecole-alpha.classeo.local/api/me/sessions', [
'headers' => [
'Accept' => 'application/json',
],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Test that the session repository correctly stores and retrieves sessions.
* This is an integration test of the repository without HTTP layer.
*/
#[Test]
public function sessionRepositoryCanStoreAndRetrieveSessions(): void
{
$container = static::getContainer();
$sessionRepository = $container->get(SessionRepository::class);
$familyId = TokenFamilyId::generate();
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$session = Session::create(
familyId: $familyId,
userId: $userId,
tenantId: $tenantId,
deviceInfo: DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
location: Location::fromIp('192.168.1.1', 'France', 'Paris'),
createdAt: new DateTimeImmutable(),
);
$sessionRepository->save($session, 86400);
// Retrieve by family ID
$retrieved = $sessionRepository->findByFamilyId($familyId);
self::assertNotNull($retrieved);
self::assertTrue($retrieved->familyId->equals($familyId));
self::assertSame('Desktop', $retrieved->deviceInfo->device);
self::assertSame('Chrome 120', $retrieved->deviceInfo->browser);
// Retrieve by user ID
$userSessions = $sessionRepository->findAllByUserId($userId);
self::assertNotEmpty($userSessions);
// Cleanup
$sessionRepository->delete($familyId);
self::assertNull($sessionRepository->findByFamilyId($familyId));
}
/**
* Test that deleteAllExcept removes other sessions but keeps the specified one.
*/
#[Test]
public function sessionRepositoryDeleteAllExceptKeepsSpecifiedSession(): void
{
$container = static::getContainer();
$sessionRepository = $container->get(SessionRepository::class);
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
// Clean up any existing sessions for this user (from previous test runs)
$existingSessions = $sessionRepository->findAllByUserId($userId);
foreach ($existingSessions as $existingSession) {
$sessionRepository->delete($existingSession->familyId);
}
// Create multiple sessions
$keepFamilyId = TokenFamilyId::generate();
$deleteFamilyId1 = TokenFamilyId::generate();
$deleteFamilyId2 = TokenFamilyId::generate();
foreach ([$keepFamilyId, $deleteFamilyId1, $deleteFamilyId2] as $familyId) {
$session = Session::create(
familyId: $familyId,
userId: $userId,
tenantId: $tenantId,
deviceInfo: DeviceInfo::fromUserAgent('Test Browser'),
location: Location::unknown(),
createdAt: new DateTimeImmutable(),
);
$sessionRepository->save($session, 86400);
}
// Delete all except one
$deletedIds = $sessionRepository->deleteAllExcept($userId, $keepFamilyId);
self::assertCount(2, $deletedIds);
self::assertNotNull($sessionRepository->findByFamilyId($keepFamilyId));
self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId1));
self::assertNull($sessionRepository->findByFamilyId($deleteFamilyId2));
// Cleanup
$sessionRepository->delete($keepFamilyId);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Session;
use App\Administration\Domain\Model\Session\DeviceInfo;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DeviceInfoTest extends TestCase
{
#[Test]
#[DataProvider('userAgentProvider')]
public function fromUserAgentParsesCorrectly(
string $userAgent,
string $expectedDevice,
string $expectedBrowser,
string $expectedOs,
): void {
$deviceInfo = DeviceInfo::fromUserAgent($userAgent);
self::assertSame($expectedDevice, $deviceInfo->device);
self::assertSame($expectedBrowser, $deviceInfo->browser);
self::assertSame($expectedOs, $deviceInfo->os);
self::assertSame($userAgent, $deviceInfo->rawUserAgent);
}
/**
* @return iterable<string, array{string, string, string, string}>
*/
public static function userAgentProvider(): iterable
{
yield 'Chrome on Windows' => [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Desktop',
'Chrome 120',
'Windows 10',
];
yield 'Firefox on macOS' => [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0',
'Desktop',
'Firefox 121',
'macOS 10.15',
];
yield 'Safari on iPhone' => [
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mobile',
'Safari 17',
'iOS 17.2',
];
yield 'Chrome on Android' => [
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.144 Mobile Safari/537.36',
'Mobile',
'Chrome 120',
'Android 14',
];
yield 'Safari on iPad' => [
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Tablet',
'Safari 17',
'iPadOS 17.2',
];
yield 'Edge on Windows' => [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
'Desktop',
'Edge 120',
'Windows 10',
];
}
#[Test]
public function fromUserAgentHandlesEmptyString(): void
{
$deviceInfo = DeviceInfo::fromUserAgent('');
self::assertSame('Inconnu', $deviceInfo->device);
self::assertSame('Inconnu', $deviceInfo->browser);
self::assertSame('Inconnu', $deviceInfo->os);
}
#[Test]
public function fromUserAgentHandlesUnknownUserAgent(): void
{
$deviceInfo = DeviceInfo::fromUserAgent('Some Random Bot/1.0');
self::assertSame('Inconnu', $deviceInfo->device);
self::assertSame('Inconnu', $deviceInfo->browser);
self::assertSame('Inconnu', $deviceInfo->os);
}
#[Test]
public function reconstituteRestoresFromStorage(): void
{
$deviceInfo = DeviceInfo::reconstitute(
device: 'Desktop',
browser: 'Chrome 120',
os: 'Windows 10',
rawUserAgent: 'Mozilla/5.0...',
);
self::assertSame('Desktop', $deviceInfo->device);
self::assertSame('Chrome 120', $deviceInfo->browser);
self::assertSame('Windows 10', $deviceInfo->os);
self::assertSame('Mozilla/5.0...', $deviceInfo->rawUserAgent);
}
#[Test]
public function isMobileReturnsTrueForMobileDevices(): void
{
$mobile = DeviceInfo::fromUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15',
);
$desktop = DeviceInfo::fromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
);
self::assertTrue($mobile->isMobile());
self::assertFalse($desktop->isMobile());
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Session;
use App\Administration\Domain\Model\Session\Location;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class LocationTest extends TestCase
{
#[Test]
public function fromIpCreatesLocationWithCountryAndCity(): void
{
$location = Location::fromIp('192.168.1.1', 'France', 'Paris');
self::assertSame('France', $location->country);
self::assertSame('Paris', $location->city);
self::assertSame('192.168.1.1', $location->ip);
}
#[Test]
public function fromIpCreatesLocationWithCountryOnly(): void
{
$location = Location::fromIp('10.0.0.1', 'Germany', null);
self::assertSame('Germany', $location->country);
self::assertNull($location->city);
}
#[Test]
public function unknownCreatesUnknownLocation(): void
{
$location = Location::unknown();
self::assertNull($location->country);
self::assertNull($location->city);
self::assertNull($location->ip);
}
#[Test]
public function formatReturnsCountryAndCity(): void
{
$location = Location::fromIp('192.168.1.1', 'France', 'Paris');
self::assertSame('France, Paris', $location->format());
}
#[Test]
public function formatReturnsCountryOnlyWhenNoCityAvailable(): void
{
$location = Location::fromIp('10.0.0.1', 'Germany', null);
self::assertSame('Germany', $location->format());
}
#[Test]
public function formatReturnsInconnuWhenUnknown(): void
{
$location = Location::unknown();
self::assertSame('Inconnu', $location->format());
}
#[Test]
public function formatReturnsInconnuForPrivateIpRanges(): void
{
$location = Location::fromIp('192.168.1.1', null, null);
self::assertSame('Inconnu', $location->format());
}
#[Test]
public function reconstituteRestoresFromStorage(): void
{
$location = Location::reconstitute(
ip: '8.8.8.8',
country: 'United States',
city: 'Mountain View',
);
self::assertSame('8.8.8.8', $location->ip);
self::assertSame('United States', $location->country);
self::assertSame('Mountain View', $location->city);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\Session;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\Session\DeviceInfo;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
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 SessionTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
#[Test]
public function createGeneratesSessionWithCorrectData(): void
{
$familyId = TokenFamilyId::generate();
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$deviceInfo = DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0');
$location = Location::fromIp('192.168.1.1', 'France', 'Paris');
$createdAt = new DateTimeImmutable('2026-01-31 10:00:00');
$session = Session::create(
$familyId,
$userId,
$tenantId,
$deviceInfo,
$location,
$createdAt,
);
self::assertTrue($session->familyId->equals($familyId));
self::assertTrue($session->userId->equals($userId));
self::assertTrue($session->tenantId->equals($tenantId));
self::assertSame($deviceInfo, $session->deviceInfo);
self::assertSame($location, $session->location);
self::assertEquals($createdAt, $session->createdAt);
self::assertEquals($createdAt, $session->lastActivityAt);
}
#[Test]
public function updateActivityUpdatesLastActivityTimestamp(): void
{
$session = $this->createSession();
$newActivityAt = new DateTimeImmutable('2026-01-31 14:30:00');
$updatedSession = $session->updateActivity($newActivityAt);
self::assertEquals($newActivityAt, $updatedSession->lastActivityAt);
self::assertEquals($session->createdAt, $updatedSession->createdAt);
self::assertTrue($session->familyId->equals($updatedSession->familyId));
}
#[Test]
public function isCurrentReturnsTrueForMatchingFamilyId(): void
{
$familyId = TokenFamilyId::generate();
$session = Session::create(
$familyId,
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceInfo::fromUserAgent('Mozilla/5.0'),
Location::unknown(),
new DateTimeImmutable(),
);
self::assertTrue($session->isCurrent($familyId));
self::assertFalse($session->isCurrent(TokenFamilyId::generate()));
}
#[Test]
public function belongsToUserReturnsTrueForMatchingUserId(): void
{
$userId = UserId::generate();
$session = Session::create(
TokenFamilyId::generate(),
$userId,
TenantId::fromString(self::TENANT_ID),
DeviceInfo::fromUserAgent('Mozilla/5.0'),
Location::unknown(),
new DateTimeImmutable(),
);
self::assertTrue($session->belongsToUser($userId));
self::assertFalse($session->belongsToUser(UserId::generate()));
}
#[Test]
public function reconstituteRestoresSessionFromStorage(): void
{
$familyId = TokenFamilyId::generate();
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$deviceInfo = DeviceInfo::fromUserAgent('Mozilla/5.0');
$location = Location::fromIp('10.0.0.1', 'Germany', 'Berlin');
$createdAt = new DateTimeImmutable('2026-01-20 08:00:00');
$lastActivityAt = new DateTimeImmutable('2026-01-31 16:00:00');
$session = Session::reconstitute(
$familyId,
$userId,
$tenantId,
$deviceInfo,
$location,
$createdAt,
$lastActivityAt,
);
self::assertTrue($session->familyId->equals($familyId));
self::assertEquals($createdAt, $session->createdAt);
self::assertEquals($lastActivityAt, $session->lastActivityAt);
}
private function createSession(): Session
{
return Session::create(
TokenFamilyId::generate(),
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
Location::fromIp('192.168.1.1', 'France', 'Paris'),
new DateTimeImmutable('2026-01-31 10:00:00'),
);
}
}

View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Api\Controller;
use App\Administration\Domain\Event\ToutesSessionsInvalidees;
use App\Administration\Domain\Exception\SessionNotFoundException;
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\Session\DeviceInfo;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\RefreshTokenRepository;
use App\Administration\Domain\Repository\SessionRepository;
use App\Administration\Infrastructure\Api\Controller\SessionsController;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class SessionsControllerTest extends TestCase
{
private Security&MockObject $security;
private SessionRepository&MockObject $sessionRepository;
private RefreshTokenRepository&MockObject $refreshTokenRepository;
private MessageBusInterface&MockObject $eventBus;
private Clock&MockObject $clock;
private SessionsController $controller;
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440000';
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
private const string OTHER_TENANT_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
$this->sessionRepository = $this->createMock(SessionRepository::class);
$this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class);
$this->eventBus = $this->createMock(MessageBusInterface::class);
$this->clock = $this->createMock(Clock::class);
$this->controller = new SessionsController(
$this->security,
$this->sessionRepository,
$this->refreshTokenRepository,
$this->eventBus,
$this->clock,
);
}
#[Test]
public function revokeReturnsNotFoundWhenFamilyIdIsMalformed(): void
{
$this->mockAuthenticatedUser();
// Assert: SessionRepository::getByFamilyId() should NEVER be called
// because we fail before any business logic (InvalidArgumentException on UUID parse)
$this->sessionRepository
->expects($this->never())
->method('getByFamilyId');
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Session not found');
$request = Request::create('/api/me/sessions/not-a-uuid', 'DELETE');
$this->controller->revoke('not-a-uuid', $request);
}
#[Test]
public function revokeReturnsNotFoundWhenSessionDoesNotExist(): void
{
$this->mockAuthenticatedUser();
$familyId = TokenFamilyId::generate();
$this->sessionRepository
->expects($this->once())
->method('getByFamilyId')
->with($this->callback(static fn (TokenFamilyId $id) => $id->equals($familyId)))
->willThrowException(new SessionNotFoundException($familyId));
$this->expectException(NotFoundHttpException::class);
$this->expectExceptionMessage('Session not found');
$request = Request::create('/api/me/sessions/' . $familyId, 'DELETE');
$this->controller->revoke((string) $familyId, $request);
}
#[Test]
public function listReturnsSessionsForCurrentUserAndTenant(): void
{
$this->mockAuthenticatedUser();
$familyId = TokenFamilyId::generate();
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2024-01-15 10:00:00');
$session = Session::create(
familyId: $familyId,
userId: $userId,
tenantId: $tenantId,
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
location: Location::fromIp('192.168.1.1', 'France', 'Paris'),
createdAt: $now,
);
$this->sessionRepository
->expects($this->once())
->method('findAllByUserId')
->with($this->callback(static fn (UserId $id) => $id->equals($userId)))
->willReturn([$session]);
$request = Request::create('/api/me/sessions', 'GET');
$response = $this->controller->list($request);
$data = json_decode($response->getContent(), true);
self::assertSame(200, $response->getStatusCode());
self::assertCount(1, $data['sessions']);
self::assertSame((string) $familyId, $data['sessions'][0]['family_id']);
self::assertSame('Desktop', $data['sessions'][0]['device']);
self::assertSame('Chrome 120', $data['sessions'][0]['browser']);
self::assertSame('Windows 10', $data['sessions'][0]['os']);
self::assertSame('France, Paris', $data['sessions'][0]['location']);
self::assertFalse($data['sessions'][0]['is_current']);
}
#[Test]
public function listFilterOutSessionsFromOtherTenants(): void
{
$this->mockAuthenticatedUser();
$userId = UserId::fromString(self::USER_ID);
$currentTenantId = TenantId::fromString(self::TENANT_ID);
$otherTenantId = TenantId::fromString(self::OTHER_TENANT_ID);
$now = new DateTimeImmutable('2024-01-15 10:00:00');
$sessionSameTenant = Session::create(
familyId: TokenFamilyId::generate(),
userId: $userId,
tenantId: $currentTenantId,
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'Mozilla/5.0'),
location: Location::unknown(),
createdAt: $now,
);
$sessionOtherTenant = Session::create(
familyId: TokenFamilyId::generate(),
userId: $userId,
tenantId: $otherTenantId,
deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'iPhone'),
location: Location::unknown(),
createdAt: $now,
);
$this->sessionRepository
->method('findAllByUserId')
->willReturn([$sessionSameTenant, $sessionOtherTenant]);
$request = Request::create('/api/me/sessions', 'GET');
$response = $this->controller->list($request);
$data = json_decode($response->getContent(), true);
// Should only return the session from the current tenant
self::assertCount(1, $data['sessions']);
self::assertSame('Desktop', $data['sessions'][0]['device']);
}
#[Test]
public function listMarksCurrentSessionCorrectly(): void
{
$this->mockAuthenticatedUser();
$currentFamilyId = TokenFamilyId::generate();
$otherFamilyId = TokenFamilyId::generate();
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2024-01-15 10:00:00');
$currentSession = Session::create(
familyId: $currentFamilyId,
userId: $userId,
tenantId: $tenantId,
deviceInfo: DeviceInfo::reconstitute('Desktop', 'Chrome 120', 'Windows 10', 'UA1'),
location: Location::unknown(),
createdAt: $now,
);
$otherSession = Session::create(
familyId: $otherFamilyId,
userId: $userId,
tenantId: $tenantId,
deviceInfo: DeviceInfo::reconstitute('Mobile', 'Safari 17', 'iOS 17', 'UA2'),
location: Location::unknown(),
createdAt: $now,
);
$this->sessionRepository
->method('findAllByUserId')
->willReturn([$currentSession, $otherSession]);
// Create a real RefreshToken to identify the current session
$refreshToken = RefreshToken::reconstitute(
id: RefreshTokenId::generate(),
familyId: $currentFamilyId,
userId: $userId,
tenantId: $tenantId,
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
issuedAt: $now,
expiresAt: $now->modify('+1 day'),
rotatedFrom: null,
isRotated: false,
);
$this->refreshTokenRepository
->method('find')
->willReturn($refreshToken);
// Create request with refresh token cookie
$tokenValue = $refreshToken->toTokenString();
$request = Request::create('/api/me/sessions', 'GET');
$request->cookies->set('refresh_token', $tokenValue);
$response = $this->controller->list($request);
$data = json_decode($response->getContent(), true);
// Current session should be first (sorted) and marked as current
self::assertCount(2, $data['sessions']);
self::assertTrue($data['sessions'][0]['is_current']);
self::assertSame((string) $currentFamilyId, $data['sessions'][0]['family_id']);
self::assertFalse($data['sessions'][1]['is_current']);
}
#[Test]
public function revokeAllDeletesAllSessionsExceptCurrent(): void
{
$this->mockAuthenticatedUser();
$currentFamilyId = TokenFamilyId::generate();
$deletedFamilyId1 = TokenFamilyId::generate();
$deletedFamilyId2 = TokenFamilyId::generate();
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2024-01-15 10:00:00');
$this->clock->method('now')->willReturn($now);
// Create a real RefreshToken for current session identification
$refreshToken = RefreshToken::reconstitute(
id: RefreshTokenId::generate(),
familyId: $currentFamilyId,
userId: $userId,
tenantId: $tenantId,
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
issuedAt: $now,
expiresAt: $now->modify('+1 day'),
rotatedFrom: null,
isRotated: false,
);
$this->refreshTokenRepository->method('find')->willReturn($refreshToken);
// Mock deleteAllExcept
$this->sessionRepository
->expects($this->once())
->method('deleteAllExcept')
->with(
$this->callback(static fn (UserId $id) => $id->equals($userId)),
$this->callback(static fn (TokenFamilyId $id) => $id->equals($currentFamilyId)),
)
->willReturn([$deletedFamilyId1, $deletedFamilyId2]);
// Expect invalidateFamily to be called for each deleted session
$this->refreshTokenRepository
->expects($this->exactly(2))
->method('invalidateFamily');
// Expect event to be dispatched
$this->eventBus
->expects($this->once())
->method('dispatch')
->with($this->isInstanceOf(ToutesSessionsInvalidees::class))
->willReturn(new Envelope(new stdClass()));
$tokenValue = $refreshToken->toTokenString();
$request = Request::create('/api/me/sessions', 'DELETE');
$request->cookies->set('refresh_token', $tokenValue);
$request->headers->set('User-Agent', 'Test Browser');
$response = $this->controller->revokeAll($request);
$data = json_decode($response->getContent(), true);
self::assertSame(200, $response->getStatusCode());
self::assertSame('All other sessions revoked', $data['message']);
self::assertSame(2, $data['revoked_count']);
}
#[Test]
public function revokeAllDoesNotDispatchEventWhenNoSessionsRevoked(): void
{
$this->mockAuthenticatedUser();
$currentFamilyId = TokenFamilyId::generate();
$userId = UserId::fromString(self::USER_ID);
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2024-01-15 10:00:00');
$this->clock->method('now')->willReturn($now);
// Create a real RefreshToken
$refreshToken = RefreshToken::reconstitute(
id: RefreshTokenId::generate(),
familyId: $currentFamilyId,
userId: $userId,
tenantId: $tenantId,
deviceFingerprint: DeviceFingerprint::fromString('test-fingerprint'),
issuedAt: $now,
expiresAt: $now->modify('+1 day'),
rotatedFrom: null,
isRotated: false,
);
$this->refreshTokenRepository->method('find')->willReturn($refreshToken);
// No sessions to delete
$this->sessionRepository
->method('deleteAllExcept')
->willReturn([]);
// Event should NOT be dispatched
$this->eventBus
->expects($this->never())
->method('dispatch');
$tokenValue = $refreshToken->toTokenString();
$request = Request::create('/api/me/sessions', 'DELETE');
$request->cookies->set('refresh_token', $tokenValue);
$response = $this->controller->revokeAll($request);
$data = json_decode($response->getContent(), true);
self::assertSame(200, $response->getStatusCode());
self::assertSame(0, $data['revoked_count']);
}
private function mockAuthenticatedUser(): void
{
$securityUser = new SecurityUser(
UserId::fromString(self::USER_ID),
'test@example.com',
'hashed_password',
TenantId::fromString(self::TENANT_ID),
['ROLE_USER'],
);
$this->security->method('getUser')->willReturn($securityUser);
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Redis;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use App\Administration\Domain\Model\Session\DeviceInfo;
use App\Administration\Domain\Model\Session\Location;
use App\Administration\Domain\Model\Session\Session;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\Redis\RedisSessionRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class RedisSessionRepositoryTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private ArrayAdapter $cache;
private RedisSessionRepository $repository;
protected function setUp(): void
{
$this->cache = new ArrayAdapter();
$this->repository = new RedisSessionRepository($this->cache);
}
#[Test]
public function savePersistsSession(): void
{
$session = $this->createSession();
$this->repository->save($session, 86400);
$retrieved = $this->repository->findByFamilyId($session->familyId);
self::assertNotNull($retrieved);
self::assertTrue($session->familyId->equals($retrieved->familyId));
}
#[Test]
public function findByFamilyIdReturnsNullWhenNotFound(): void
{
$nonExistentId = TokenFamilyId::generate();
$result = $this->repository->findByFamilyId($nonExistentId);
self::assertNull($result);
}
#[Test]
public function findAllByUserIdReturnsAllUserSessions(): void
{
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$session1 = Session::create(
TokenFamilyId::generate(),
$userId,
$tenantId,
DeviceInfo::fromUserAgent('Chrome'),
Location::unknown(),
new DateTimeImmutable(),
);
$session2 = Session::create(
TokenFamilyId::generate(),
$userId,
$tenantId,
DeviceInfo::fromUserAgent('Firefox'),
Location::unknown(),
new DateTimeImmutable(),
);
$otherUserSession = Session::create(
TokenFamilyId::generate(),
UserId::generate(),
$tenantId,
DeviceInfo::fromUserAgent('Safari'),
Location::unknown(),
new DateTimeImmutable(),
);
$this->repository->save($session1, 86400);
$this->repository->save($session2, 86400);
$this->repository->save($otherUserSession, 86400);
$sessions = $this->repository->findAllByUserId($userId);
self::assertCount(2, $sessions);
$familyIds = array_map(
static fn (Session $s) => (string) $s->familyId,
$sessions,
);
self::assertContains((string) $session1->familyId, $familyIds);
self::assertContains((string) $session2->familyId, $familyIds);
}
#[Test]
public function deleteRemovesSession(): void
{
$session = $this->createSession();
$this->repository->save($session, 86400);
$this->repository->delete($session->familyId);
self::assertNull($this->repository->findByFamilyId($session->familyId));
}
#[Test]
public function deleteRemovesSessionFromUserIndex(): void
{
$userId = UserId::generate();
$session = Session::create(
TokenFamilyId::generate(),
$userId,
TenantId::fromString(self::TENANT_ID),
DeviceInfo::fromUserAgent('Chrome'),
Location::unknown(),
new DateTimeImmutable(),
);
$this->repository->save($session, 86400);
$this->repository->delete($session->familyId);
$sessions = $this->repository->findAllByUserId($userId);
self::assertCount(0, $sessions);
}
#[Test]
public function deleteAllExceptRemovesOtherSessions(): void
{
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$currentFamilyId = TokenFamilyId::generate();
$currentSession = Session::create(
$currentFamilyId,
$userId,
$tenantId,
DeviceInfo::fromUserAgent('Chrome'),
Location::unknown(),
new DateTimeImmutable(),
);
$otherSession1 = Session::create(
TokenFamilyId::generate(),
$userId,
$tenantId,
DeviceInfo::fromUserAgent('Firefox'),
Location::unknown(),
new DateTimeImmutable(),
);
$otherSession2 = Session::create(
TokenFamilyId::generate(),
$userId,
$tenantId,
DeviceInfo::fromUserAgent('Safari'),
Location::unknown(),
new DateTimeImmutable(),
);
$this->repository->save($currentSession, 86400);
$this->repository->save($otherSession1, 86400);
$this->repository->save($otherSession2, 86400);
$deletedFamilyIds = $this->repository->deleteAllExcept($userId, $currentFamilyId);
self::assertCount(2, $deletedFamilyIds);
self::assertContains((string) $otherSession1->familyId, array_map(static fn ($id) => (string) $id, $deletedFamilyIds));
self::assertContains((string) $otherSession2->familyId, array_map(static fn ($id) => (string) $id, $deletedFamilyIds));
$sessions = $this->repository->findAllByUserId($userId);
self::assertCount(1, $sessions);
self::assertTrue($sessions[0]->familyId->equals($currentFamilyId));
}
#[Test]
public function updateActivityUpdatesLastActivityTimestamp(): void
{
$session = $this->createSession();
$this->repository->save($session, 86400);
$newActivityAt = new DateTimeImmutable('2026-02-01 15:30:00');
$remainingTtl = 43200; // 12 hours remaining
$this->repository->updateActivity($session->familyId, $newActivityAt, $remainingTtl);
$updated = $this->repository->findByFamilyId($session->familyId);
self::assertNotNull($updated);
self::assertEquals($newActivityAt, $updated->lastActivityAt);
}
#[Test]
public function savePreservesAllSessionData(): void
{
$familyId = TokenFamilyId::generate();
$userId = UserId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$deviceInfo = DeviceInfo::fromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
);
$location = Location::fromIp('8.8.8.8', 'United States', 'Mountain View');
$createdAt = new DateTimeImmutable('2026-01-31 10:00:00');
$session = Session::create(
$familyId,
$userId,
$tenantId,
$deviceInfo,
$location,
$createdAt,
);
$this->repository->save($session, 86400);
$retrieved = $this->repository->findByFamilyId($familyId);
self::assertNotNull($retrieved);
self::assertTrue($familyId->equals($retrieved->familyId));
self::assertTrue($userId->equals($retrieved->userId));
self::assertTrue($tenantId->equals($retrieved->tenantId));
self::assertSame('Desktop', $retrieved->deviceInfo->device);
self::assertSame('Chrome 120', $retrieved->deviceInfo->browser);
self::assertSame('Windows 10', $retrieved->deviceInfo->os);
self::assertSame('United States', $retrieved->location->country);
self::assertSame('Mountain View', $retrieved->location->city);
self::assertEquals($createdAt, $retrieved->createdAt);
}
private function createSession(): Session
{
return Session::create(
TokenFamilyId::generate(),
UserId::generate(),
TenantId::fromString(self::TENANT_ID),
DeviceInfo::fromUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'),
Location::fromIp('192.168.1.1', 'France', 'Paris'),
new DateTimeImmutable('2026-01-31 10:00:00'),
);
}
}

View File

@@ -6,6 +6,13 @@ use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
// PHPUnit sets $_SERVER['APP_ENV'] via phpunit.xml <server> directive.
// We must ensure this takes precedence over shell environment variables.
if (isset($_SERVER['APP_ENV'])) {
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'];
putenv('APP_ENV=' . $_SERVER['APP_ENV']);
}
if (file_exists(dirname(__DIR__) . '/config/bootstrap.php')) {
require dirname(__DIR__) . '/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {