feat: Infrastructure multi-tenant avec isolation par sous-domaine
Une application SaaS éducative nécessite une séparation stricte des données entre établissements scolaires. L'architecture multi-tenant par sous-domaine (ecole-alpha.classeo.local) permet cette isolation tout en utilisant une base de code unique. Le choix d'une résolution basée sur les sous-domaines plutôt que sur des headers ou tokens facilite le routage au niveau infrastructure (reverse proxy) et offre une UX plus naturelle où chaque école accède à "son" URL dédiée.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Security;
|
||||
|
||||
use App\Shared\Infrastructure\Security\TenantAccessDeniedHandler;
|
||||
use App\Shared\Infrastructure\Security\TenantAwareInterface;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Unit tests for TenantAccessDeniedHandler.
|
||||
*
|
||||
* CRITICAL: This handler converts 403 to 404 for cross-tenant access
|
||||
* to prevent enumeration attacks (revealing resource existence).
|
||||
*/
|
||||
#[CoversClass(TenantAccessDeniedHandler::class)]
|
||||
final class TenantAccessDeniedHandlerTest extends TestCase
|
||||
{
|
||||
private TenantAccessDeniedHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->handler = new TenantAccessDeniedHandler();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConvertsAccessDeniedExceptionForTenantAwareResourceTo404(): void
|
||||
{
|
||||
// Create a TenantAware resource
|
||||
$resource = $this->createMock(TenantAwareInterface::class);
|
||||
$resource->method('getTenantId')
|
||||
->willReturn(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'));
|
||||
|
||||
// Create AccessDeniedException with TenantAware subject
|
||||
$exception = new AccessDeniedException('Access Denied', null);
|
||||
// Note: Symfony's AccessDeniedException doesn't have a public setSubject method,
|
||||
// but it stores the subject internally. For this test, we'll use reflection.
|
||||
$reflection = new ReflectionClass($exception);
|
||||
$property = $reflection->getProperty('subject');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($exception, $resource);
|
||||
|
||||
$event = $this->createExceptionEvent($exception);
|
||||
|
||||
$this->handler->onKernelException($event);
|
||||
|
||||
// CRITICAL: Must return 404, not 403
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode());
|
||||
|
||||
// Verify error message is generic (no information leakage)
|
||||
$content = json_decode((string) $event->getResponse()->getContent(), true);
|
||||
self::assertSame('Resource not found', $content['message']);
|
||||
self::assertSame(Response::HTTP_NOT_FOUND, $content['status']);
|
||||
self::assertSame('https://classeo.fr/errors/resource-not-found', $content['type']);
|
||||
|
||||
// Must NOT reveal tenant information
|
||||
self::assertArrayNotHasKey('tenant', $content);
|
||||
self::assertArrayNotHasKey('resource', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotModifyAccessDeniedExceptionForNonTenantAwareResources(): void
|
||||
{
|
||||
// Regular access denied (not tenant-related)
|
||||
$exception = new AccessDeniedException('Access Denied');
|
||||
|
||||
$event = $this->createExceptionEvent($exception);
|
||||
|
||||
$this->handler->onKernelException($event);
|
||||
|
||||
// Should NOT set a response - let other handlers deal with it
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIgnoresOtherExceptions(): void
|
||||
{
|
||||
// Non-AccessDeniedException
|
||||
$exception = new RuntimeException('Some error');
|
||||
|
||||
$event = $this->createExceptionEvent($exception);
|
||||
|
||||
$this->handler->onKernelException($event);
|
||||
|
||||
// Should NOT set a response
|
||||
self::assertFalse($event->hasResponse());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturns404NotRevealingResourceExistence(): void
|
||||
{
|
||||
// This test specifically validates the security requirement:
|
||||
// 403 reveals resource exists, 404 hides it
|
||||
|
||||
$resource = $this->createMock(TenantAwareInterface::class);
|
||||
$resource->method('getTenantId')
|
||||
->willReturn(TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'));
|
||||
|
||||
$exception = new AccessDeniedException('You cannot access this resource', null);
|
||||
$reflection = new ReflectionClass($exception);
|
||||
$property = $reflection->getProperty('subject');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($exception, $resource);
|
||||
|
||||
$event = $this->createExceptionEvent($exception);
|
||||
|
||||
$this->handler->onKernelException($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
|
||||
// MUST be 404 (not 403) to prevent enumeration
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertNotSame(403, $response->getStatusCode(), 'SECURITY: Must be 404, not 403');
|
||||
}
|
||||
|
||||
private function createExceptionEvent(Throwable $exception): ExceptionEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$request = Request::create('/api/resource/123');
|
||||
|
||||
return new ExceptionEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user