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:
126
backend/tests/Architecture/BoundedContextIsolationTest.php
Normal file
126
backend/tests/Architecture/BoundedContextIsolationTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use PHPat\Selector\Selector;
|
||||
use PHPat\Test\Builder\BuildStep;
|
||||
use PHPat\Test\PHPat;
|
||||
|
||||
/**
|
||||
* Tests ensuring Bounded Contexts are properly isolated.
|
||||
*
|
||||
* Bounded Contexts must communicate through events (via Shared),
|
||||
* not through direct dependencies.
|
||||
*/
|
||||
final class BoundedContextIsolationTest
|
||||
{
|
||||
public function test_scolarite_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_scolarite_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_scolarite_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_communication_should_not_depend_on_administration(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_scolarite(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_vie_scolaire(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
|
||||
public function test_administration_should_not_depend_on_communication(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication'))
|
||||
->because('Bounded Contexts must communicate through events, not direct calls');
|
||||
}
|
||||
}
|
||||
129
backend/tests/Architecture/DomainPurityTest.php
Normal file
129
backend/tests/Architecture/DomainPurityTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use PHPat\Selector\Selector;
|
||||
use PHPat\Test\Builder\BuildStep;
|
||||
use PHPat\Test\PHPat;
|
||||
|
||||
/**
|
||||
* Tests ensuring Domain layer purity across all Bounded Contexts.
|
||||
*
|
||||
* The Domain layer must be pure PHP without framework dependencies.
|
||||
* This is critical for DDD architecture and testability.
|
||||
*/
|
||||
final class DomainPurityTest
|
||||
{
|
||||
public function test_scolarite_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Scolarite\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\VieScolaire\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_communication_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Communication\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_administration_domain_should_not_depend_on_infrastructure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('App\Administration\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
)
|
||||
->because('Domain must be pure PHP without Infrastructure/framework dependencies');
|
||||
}
|
||||
|
||||
public function test_scolarite_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Scolarite\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_vie_scolaire_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\VieScolaire\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_communication_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Communication\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Communication\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_administration_domain_should_not_depend_on_application(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Administration\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(Selector::inNamespace('App\Administration\Application'))
|
||||
->because('Domain must not know about Application layer');
|
||||
}
|
||||
|
||||
public function test_shared_domain_should_be_pure(): BuildStep
|
||||
{
|
||||
return PHPat::rule()
|
||||
->classes(Selector::inNamespace('App\Shared\Domain'))
|
||||
->shouldNotDependOn()
|
||||
->classes(
|
||||
Selector::inNamespace('Symfony'),
|
||||
Selector::inNamespace('Doctrine'),
|
||||
Selector::inNamespace('ApiPlatform'),
|
||||
Selector::inNamespace('App\Shared\Infrastructure'),
|
||||
Selector::inNamespace('App\Shared\Application'),
|
||||
)
|
||||
->because('Shared Domain must be pure PHP');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Security\TenantAwareInterface;
|
||||
use App\Shared\Infrastructure\Security\TenantVoter;
|
||||
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantMiddleware;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Cross-tenant isolation tests verifying that:
|
||||
* 1. Users from tenant A cannot see data from tenant B
|
||||
* 2. Subdomain mismatch results in rejection
|
||||
* 3. Unknown subdomains return 404
|
||||
* 4. Resource access for other tenants returns 404 (not 403)
|
||||
*/
|
||||
#[CoversClass(TenantMiddleware::class)]
|
||||
#[CoversClass(TenantVoter::class)]
|
||||
#[CoversClass(TenantResolver::class)]
|
||||
final class CrossTenantIsolationTest extends TestCase
|
||||
{
|
||||
private const string BASE_DOMAIN = 'classeo.local';
|
||||
|
||||
private TenantId $tenantIdAlpha;
|
||||
private TenantId $tenantIdBeta;
|
||||
private TenantConfig $configAlpha;
|
||||
private TenantConfig $configBeta;
|
||||
private InMemoryTenantRegistry $registry;
|
||||
private TenantContext $context;
|
||||
private TenantResolver $resolver;
|
||||
private TenantMiddleware $middleware;
|
||||
private TenantVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$this->tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$this->configAlpha = new TenantConfig(
|
||||
tenantId: $this->tenantIdAlpha,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
$this->configBeta = new TenantConfig(
|
||||
tenantId: $this->tenantIdBeta,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta',
|
||||
);
|
||||
|
||||
$this->registry = new InMemoryTenantRegistry([$this->configAlpha, $this->configBeta]);
|
||||
$this->context = new TenantContext();
|
||||
$this->resolver = new TenantResolver($this->registry, self::BASE_DOMAIN);
|
||||
$this->middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
$this->voter = new TenantVoter($this->context);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userFromTenantACannotAccessResourceFromTenantB(): void
|
||||
{
|
||||
// User authenticates on tenant Alpha
|
||||
$this->context->setCurrentTenant($this->configAlpha);
|
||||
|
||||
// Create a resource belonging to tenant Beta
|
||||
$resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
||||
|
||||
// Create authenticated user token
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
// Vote should DENY access
|
||||
$result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userFromTenantACanAccessOwnResources(): void
|
||||
{
|
||||
// User authenticates on tenant Alpha
|
||||
$this->context->setCurrentTenant($this->configAlpha);
|
||||
|
||||
// Create a resource belonging to tenant Alpha
|
||||
$resourceFromAlpha = $this->createTenantAwareResource($this->tenantIdAlpha);
|
||||
|
||||
// Create authenticated user token
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
// Vote should GRANT access
|
||||
$result = $this->voter->vote($token, $resourceFromAlpha, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unknownSubdomainReturns404(): void
|
||||
{
|
||||
$request = Request::create('https://ecole-inexistant.classeo.local/api/dashboard');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
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::assertArrayNotHasKey('subdomain', $content);
|
||||
self::assertArrayNotHasKey('tenant', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validSubdomainSetsContext(): void
|
||||
{
|
||||
$request = Request::create('https://ecole-alpha.classeo.local/api/dashboard');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse()); // No error response
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
self::assertTrue($this->tenantIdAlpha->equals($this->context->getCurrentTenantId()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function accessToResourceFromOtherTenantReturns404NotRevealingExistence(): void
|
||||
{
|
||||
// This test verifies the critical security requirement:
|
||||
// When denied access to a resource from another tenant,
|
||||
// the response MUST be 404 (not 403) to prevent enumeration attacks.
|
||||
|
||||
// User is authenticated on tenant Alpha
|
||||
$this->context->setCurrentTenant($this->configAlpha);
|
||||
|
||||
// Resource exists in tenant Beta
|
||||
$resourceFromBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
||||
|
||||
// Create authenticated user token
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
// Vote should DENY (which will be converted to 404 by TenantAccessDeniedHandler)
|
||||
$result = $this->voter->vote($token, $resourceFromBeta, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
// The voter returns DENIED, not ABSTAIN
|
||||
// This ensures the access is actively denied rather than just not supported
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
|
||||
// The AccessDeniedHandler will convert this to 404
|
||||
// (tested separately in TenantAccessDeniedHandler tests)
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tenantContextIsClearedOnRequestTermination(): void
|
||||
{
|
||||
// Set a tenant
|
||||
$this->context->setCurrentTenant($this->configAlpha);
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
|
||||
// Terminate request
|
||||
$this->middleware->onKernelTerminate();
|
||||
|
||||
// Context should be cleared
|
||||
self::assertFalse($this->context->hasTenant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function subdomainMismatchWithAuthenticatedUserFromDifferentTenant(): void
|
||||
{
|
||||
// User tries to access ecole-beta.classeo.local
|
||||
$request = Request::create('https://ecole-beta.classeo.local/api/dashboard');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
// Context should be set to Beta (the subdomain in the request)
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
self::assertTrue($this->tenantIdBeta->equals($this->context->getCurrentTenantId()));
|
||||
|
||||
// If a user's JWT was issued by Alpha but they're accessing Beta,
|
||||
// the authentication layer should reject the token (not tested here,
|
||||
// that's handled by JWT validation which checks tenant claims)
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reservedSubdomainsAreRejected(): void
|
||||
{
|
||||
$reservedSubdomains = ['www', 'api', 'admin', 'static', 'cdn', 'mail'];
|
||||
|
||||
foreach ($reservedSubdomains as $subdomain) {
|
||||
$this->context->clear();
|
||||
|
||||
$request = Request::create("https://{$subdomain}.classeo.local/api/test");
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
self::assertTrue(
|
||||
$event->hasResponse(),
|
||||
"Expected 404 response for reserved subdomain: {$subdomain}"
|
||||
);
|
||||
self::assertSame(
|
||||
Response::HTTP_NOT_FOUND,
|
||||
$event->getResponse()?->getStatusCode(),
|
||||
"Expected 404 for reserved subdomain: {$subdomain}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function testWithTwoTenantsMinimum(): void
|
||||
{
|
||||
// This test ensures we always test with at least 2 tenants
|
||||
// as per the project's critical rules
|
||||
|
||||
// Verify both tenants are set up
|
||||
self::assertTrue($this->registry->exists('ecole-alpha'));
|
||||
self::assertTrue($this->registry->exists('ecole-beta'));
|
||||
|
||||
// Test Alpha isolation
|
||||
$this->context->setCurrentTenant($this->configAlpha);
|
||||
$resourceAlpha = $this->createTenantAwareResource($this->tenantIdAlpha);
|
||||
$resourceBeta = $this->createTenantAwareResource($this->tenantIdBeta);
|
||||
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]),
|
||||
'Alpha user should access Alpha resource'
|
||||
);
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]),
|
||||
'Alpha user should NOT access Beta resource'
|
||||
);
|
||||
|
||||
// Test Beta isolation
|
||||
$this->context->setCurrentTenant($this->configBeta);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->voter->vote($token, $resourceAlpha, [TenantVoter::ATTRIBUTE]),
|
||||
'Beta user should NOT access Alpha resource'
|
||||
);
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->voter->vote($token, $resourceBeta, [TenantVoter::ATTRIBUTE]),
|
||||
'Beta user should access Beta resource'
|
||||
);
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
private function createTenantAwareResource(TenantId $tenantId): TenantAwareInterface
|
||||
{
|
||||
$resource = $this->createMock(TenantAwareInterface::class);
|
||||
$resource->method('getTenantId')->willReturn($tenantId);
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantEntityManagerFactory;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantEntityManagerFactory::class)]
|
||||
final class TenantDatabaseCreationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCreatesConnectionForTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
$clock = $this->createMock(Clock::class);
|
||||
$clock->method('now')->willReturn(new DateTimeImmutable());
|
||||
|
||||
$ormConfig = new Configuration();
|
||||
$ormConfig->setProxyDir(sys_get_temp_dir());
|
||||
$ormConfig->setProxyNamespace('DoctrineProxies');
|
||||
$ormConfig->setAutoGenerateProxyClasses(true);
|
||||
$ormConfig->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$ormConfig->enableNativeLazyObjects(true);
|
||||
|
||||
$factory = new TenantEntityManagerFactory($registry, $clock, $ormConfig);
|
||||
|
||||
$em = $factory->getForTenant($tenantId);
|
||||
|
||||
// Verify connection is working
|
||||
$connection = $em->getConnection();
|
||||
self::assertTrue($connection->isConnected() || $connection->connect());
|
||||
|
||||
// Verify we can execute queries
|
||||
$result = $connection->executeQuery('SELECT 1 as test');
|
||||
self::assertEquals(1, $result->fetchOne());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itMaintainsIsolationBetweenTenants(): void
|
||||
{
|
||||
$tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: $tenantIdAlpha,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: $tenantIdBeta,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$configAlpha, $configBeta]);
|
||||
$clock = $this->createMock(Clock::class);
|
||||
$clock->method('now')->willReturn(new DateTimeImmutable());
|
||||
|
||||
$ormConfig = new Configuration();
|
||||
$ormConfig->setProxyDir(sys_get_temp_dir());
|
||||
$ormConfig->setProxyNamespace('DoctrineProxies');
|
||||
$ormConfig->setAutoGenerateProxyClasses(true);
|
||||
$ormConfig->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$ormConfig->enableNativeLazyObjects(true);
|
||||
|
||||
$factory = new TenantEntityManagerFactory($registry, $clock, $ormConfig);
|
||||
|
||||
$emAlpha = $factory->getForTenant($tenantIdAlpha);
|
||||
$emBeta = $factory->getForTenant($tenantIdBeta);
|
||||
|
||||
// Create table in Alpha
|
||||
$emAlpha->getConnection()->executeStatement(
|
||||
'CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)'
|
||||
);
|
||||
$emAlpha->getConnection()->executeStatement(
|
||||
"INSERT INTO test_data (id, value) VALUES (1, 'alpha_data')"
|
||||
);
|
||||
|
||||
// Create different table in Beta
|
||||
$emBeta->getConnection()->executeStatement(
|
||||
'CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)'
|
||||
);
|
||||
$emBeta->getConnection()->executeStatement(
|
||||
"INSERT INTO test_data (id, value) VALUES (1, 'beta_data')"
|
||||
);
|
||||
|
||||
// Verify isolation - each tenant sees only their data
|
||||
$alphaValue = $emAlpha->getConnection()
|
||||
->executeQuery('SELECT value FROM test_data WHERE id = 1')
|
||||
->fetchOne();
|
||||
$betaValue = $emBeta->getConnection()
|
||||
->executeQuery('SELECT value FROM test_data WHERE id = 1')
|
||||
->fetchOne();
|
||||
|
||||
self::assertSame('alpha_data', $alphaValue);
|
||||
self::assertSame('beta_data', $betaValue);
|
||||
self::assertNotSame($alphaValue, $betaValue);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Security;
|
||||
|
||||
use App\Shared\Infrastructure\Security\TenantAwareInterface;
|
||||
use App\Shared\Infrastructure\Security\TenantVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[CoversClass(TenantVoter::class)]
|
||||
final class TenantVoterTest extends TestCase
|
||||
{
|
||||
private TenantContext $tenantContext;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenantContext = new TenantContext();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForNonTenantAwareSubjects(): void
|
||||
{
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$subject = new stdClass();
|
||||
|
||||
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForNonTenantAccessAttributes(): void
|
||||
{
|
||||
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
|
||||
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$subject = $this->createTenantAwareSubject($tenantIdString);
|
||||
|
||||
// Voter should abstain for other attributes to not bypass other voters
|
||||
foreach (['VIEW', 'EDIT', 'DELETE', 'ROLE_ADMIN'] as $attribute) {
|
||||
$result = $voter->vote($token, $subject, [$attribute]);
|
||||
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $result, "Should abstain for: {$attribute}");
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessWhenUserNotAuthenticated(): void
|
||||
{
|
||||
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
|
||||
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
|
||||
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsAccessWhenSubjectBelongsToCurrentTenant(): void
|
||||
{
|
||||
$tenantIdString = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
$this->setCurrentTenant($tenantIdString, 'ecole-alpha');
|
||||
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$subject = $this->createTenantAwareSubject($tenantIdString);
|
||||
|
||||
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessWhenSubjectBelongsToDifferentTenant(): void
|
||||
{
|
||||
// Current tenant is alpha
|
||||
$this->setCurrentTenant('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'ecole-alpha');
|
||||
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
// Subject belongs to beta tenant
|
||||
$subject = $this->createTenantAwareSubject('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
// Should be DENIED (will be converted to 404 by access denied handler)
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessWhenNoTenantContextSet(): void
|
||||
{
|
||||
// Don't set any tenant context
|
||||
$voter = new TenantVoter($this->tenantContext);
|
||||
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$subject = $this->createTenantAwareSubject('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
|
||||
$result = $voter->vote($token, $subject, [TenantVoter::ATTRIBUTE]);
|
||||
|
||||
self::assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
private function setCurrentTenant(string $tenantIdString, string $subdomain): void
|
||||
{
|
||||
$tenantId = TenantId::fromString($tenantIdString);
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: $subdomain,
|
||||
databaseUrl: "postgresql://user:pass@localhost:5432/classeo_{$subdomain}",
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($config);
|
||||
}
|
||||
|
||||
private function createTenantAwareSubject(string $tenantIdString): TenantAwareInterface
|
||||
{
|
||||
$tenantId = TenantId::fromString($tenantIdString);
|
||||
|
||||
$subject = $this->createMock(TenantAwareInterface::class);
|
||||
$subject->method('getTenantId')->willReturn($tenantId);
|
||||
|
||||
return $subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(InMemoryTenantRegistry::class)]
|
||||
final class InMemoryTenantRegistryTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itReturnsConfigByTenantId(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
$result = $registry->getConfig($tenantId);
|
||||
|
||||
self::assertSame($config, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsConfigBySubdomain(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
$result = $registry->getBySubdomain('ecole-alpha');
|
||||
|
||||
self::assertSame($config, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForUnknownTenantId(): void
|
||||
{
|
||||
$registry = new InMemoryTenantRegistry([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$registry->getConfig(TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForUnknownSubdomain(): void
|
||||
{
|
||||
$registry = new InMemoryTenantRegistry([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$registry->getBySubdomain('ecole-inexistant');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChecksIfTenantExists(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$config]);
|
||||
|
||||
self::assertTrue($registry->exists('ecole-alpha'));
|
||||
self::assertFalse($registry->exists('ecole-inexistant'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSupportsMultipleTenants(): void
|
||||
{
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901'),
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_beta',
|
||||
);
|
||||
|
||||
$registry = new InMemoryTenantRegistry([$configAlpha, $configBeta]);
|
||||
|
||||
self::assertSame($configAlpha, $registry->getBySubdomain('ecole-alpha'));
|
||||
self::assertSame($configBeta, $registry->getBySubdomain('ecole-beta'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(TenantConfig::class)]
|
||||
final class TenantConfigTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanBeCreatedWithRequiredProperties(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$subdomain = 'ecole-alpha';
|
||||
$databaseUrl = 'postgresql://user:pass@localhost:5432/classeo_alpha';
|
||||
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: $subdomain,
|
||||
databaseUrl: $databaseUrl,
|
||||
);
|
||||
|
||||
self::assertTrue($tenantId->equals($config->tenantId));
|
||||
self::assertSame($subdomain, $config->subdomain);
|
||||
self::assertSame($databaseUrl, $config->databaseUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsImmutable(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$reflection = new ReflectionClass($config);
|
||||
|
||||
self::assertTrue($reflection->isReadonly());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotSetException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantContext::class)]
|
||||
final class TenantContextTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanSetAndRetrieveCurrentTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$context = new TenantContext();
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($tenantId->equals($context->getCurrentTenantId()));
|
||||
self::assertSame($config, $context->getCurrentTenantConfig());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenNoTenantIsSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
$this->expectException(TenantNotSetException::class);
|
||||
|
||||
$context->getCurrentTenantId();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenGettingConfigWithNoTenantSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
$this->expectException(TenantNotSetException::class);
|
||||
|
||||
$context->getCurrentTenantConfig();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanCheckIfTenantIsSet(): void
|
||||
{
|
||||
$context = new TenantContext();
|
||||
|
||||
self::assertFalse($context->hasTenant());
|
||||
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($context->hasTenant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanClearTenant(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$context = new TenantContext();
|
||||
$context->setCurrentTenant($config);
|
||||
|
||||
self::assertTrue($context->hasTenant());
|
||||
|
||||
$context->clear();
|
||||
|
||||
self::assertFalse($context->hasTenant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantEntityManagerFactory;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for TenantEntityManagerFactory.
|
||||
*
|
||||
* Note: These tests use SQLite in-memory databases which requires proper
|
||||
* Doctrine ORM configuration. For full integration testing with PostgreSQL,
|
||||
* see the Integration tests.
|
||||
*/
|
||||
#[CoversClass(TenantEntityManagerFactory::class)]
|
||||
final class TenantEntityManagerFactoryTest extends TestCase
|
||||
{
|
||||
private TenantRegistry $registry;
|
||||
private Clock $clock;
|
||||
private Configuration $ormConfiguration;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = $this->createMock(TenantRegistry::class);
|
||||
$this->clock = $this->createMock(Clock::class);
|
||||
$this->ormConfiguration = $this->createOrmConfiguration();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEntityManagerForTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$entityManager = $factory->getForTenant($tenantId);
|
||||
|
||||
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSameEntityManagerForSameTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$em1 = $factory->getForTenant($tenantId);
|
||||
$em2 = $factory->getForTenant($tenantId);
|
||||
|
||||
self::assertSame($em1, $em2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsDifferentEntityManagersForDifferentTenants(): void
|
||||
{
|
||||
$tenantIdAlpha = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantIdBeta = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$configAlpha = new TenantConfig(
|
||||
tenantId: $tenantIdAlpha,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$configBeta = new TenantConfig(
|
||||
tenantId: $tenantIdBeta,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->willReturnMap([
|
||||
[$tenantIdAlpha, $configAlpha],
|
||||
[$tenantIdBeta, $configBeta],
|
||||
]);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$emAlpha = $factory->getForTenant($tenantIdAlpha);
|
||||
$emBeta = $factory->getForTenant($tenantIdBeta);
|
||||
|
||||
self::assertNotSame($emAlpha, $emBeta);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsCorrectPoolSize(): void
|
||||
{
|
||||
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
$config1 = new TenantConfig(
|
||||
tenantId: $tenantId1,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
$config2 = new TenantConfig(
|
||||
tenantId: $tenantId2,
|
||||
subdomain: 'ecole-beta',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->willReturnMap([
|
||||
[$tenantId1, $config1],
|
||||
[$tenantId2, $config2],
|
||||
]);
|
||||
$this->clock->method('now')->willReturn(new DateTimeImmutable('2026-01-30 10:00:00'));
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
self::assertSame(0, $factory->getPoolSize());
|
||||
|
||||
$factory->getForTenant($tenantId1);
|
||||
self::assertSame(1, $factory->getPoolSize());
|
||||
|
||||
$factory->getForTenant($tenantId2);
|
||||
self::assertSame(2, $factory->getPoolSize());
|
||||
|
||||
// Accessing same tenant shouldn't increase pool size
|
||||
$factory->getForTenant($tenantId1);
|
||||
self::assertSame(2, $factory->getPoolSize());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEvictsIdleConnectionsAfterTimeout(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$this->registry->method('getConfig')->with($tenantId)->willReturn($config);
|
||||
|
||||
$initialTime = new DateTimeImmutable('2026-01-30 10:00:00');
|
||||
$afterTimeout = new DateTimeImmutable('2026-01-30 10:06:00'); // 6 minutes later
|
||||
|
||||
$this->clock->method('now')->willReturnOnConsecutiveCalls(
|
||||
$initialTime, // First call - eviction check
|
||||
$initialTime, // Store lastUsed
|
||||
$afterTimeout, // Second call - eviction check (finds idle)
|
||||
$afterTimeout, // Store lastUsed for new manager
|
||||
);
|
||||
|
||||
$factory = new TenantEntityManagerFactory(
|
||||
$this->registry,
|
||||
$this->clock,
|
||||
$this->ormConfiguration,
|
||||
);
|
||||
|
||||
$em1 = $factory->getForTenant($tenantId);
|
||||
$em2 = $factory->getForTenant($tenantId);
|
||||
|
||||
// Due to idle eviction, we should have a new entity manager
|
||||
self::assertNotSame($em1, $em2);
|
||||
}
|
||||
|
||||
private function createOrmConfiguration(): Configuration
|
||||
{
|
||||
$config = new Configuration();
|
||||
$config->setProxyDir(sys_get_temp_dir() . '/doctrine_proxies_' . uniqid());
|
||||
$config->setProxyNamespace('DoctrineProxies');
|
||||
$config->setAutoGenerateProxyClasses(true);
|
||||
$config->setMetadataDriverImpl(new AttributeDriver([]));
|
||||
$config->enableNativeLazyObjects(true);
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
#[CoversClass(TenantId::class)]
|
||||
final class TenantIdTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCanBeGeneratedWithRandomUuid(): void
|
||||
{
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
self::assertTrue(Uuid::isValid((string) $tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeCreatedFromString(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
$tenantId = TenantId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $tenantId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twoTenantIdsWithSameValueAreEqual(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
$tenantId1 = TenantId::fromString($uuid);
|
||||
$tenantId2 = TenantId::fromString($uuid);
|
||||
|
||||
self::assertTrue($tenantId1->equals($tenantId2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twoTenantIdsWithDifferentValuesAreNotEqual(): void
|
||||
{
|
||||
$tenantId1 = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$tenantId2 = TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901');
|
||||
|
||||
self::assertFalse($tenantId1->equals($tenantId2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCanBeConvertedToString(): void
|
||||
{
|
||||
$uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
$tenantId = TenantId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantMiddleware;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
#[CoversClass(TenantMiddleware::class)]
|
||||
final class TenantMiddlewareTest extends TestCase
|
||||
{
|
||||
private TenantResolver $resolver;
|
||||
private TenantContext $context;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->resolver = $this->createMock(TenantResolver::class);
|
||||
$this->context = new TenantContext();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsTenantContextForValidTenant(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->resolver->method('resolve')
|
||||
->with('ecole-alpha.classeo.local')
|
||||
->willReturn($config);
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://ecole-alpha.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
self::assertTrue($tenantId->equals($this->context->getCurrentTenantId()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturns404ForNonExistentTenant(): void
|
||||
{
|
||||
$this->resolver->method('resolve')
|
||||
->with('ecole-inexistant.classeo.local')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://ecole-inexistant.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
self::assertSame(Response::HTTP_NOT_FOUND, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsGenericErrorMessageFor404(): void
|
||||
{
|
||||
$this->resolver->method('resolve')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('test'));
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
|
||||
$request = Request::create('https://test.classeo.local/api/test');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$middleware->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
|
||||
$content = json_decode((string) $response->getContent(), true);
|
||||
self::assertSame('Resource not found', $content['message']);
|
||||
self::assertArrayNotHasKey('subdomain', $content);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itClearsTenantContextOnTerminate(): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890'),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->context->setCurrentTenant($config);
|
||||
self::assertTrue($this->context->hasTenant());
|
||||
|
||||
$middleware = new TenantMiddleware($this->resolver, $this->context);
|
||||
$middleware->onKernelTerminate();
|
||||
|
||||
self::assertFalse($this->context->hasTenant());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRegistersCorrectEvents(): void
|
||||
{
|
||||
$events = TenantMiddleware::getSubscribedEvents();
|
||||
|
||||
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
|
||||
self::assertArrayHasKey(KernelEvents::TERMINATE, $events);
|
||||
|
||||
// Request listener should have high priority to run early
|
||||
$requestConfig = $events[KernelEvents::REQUEST];
|
||||
self::assertIsArray($requestConfig);
|
||||
self::assertSame('onKernelRequest', $requestConfig[0]);
|
||||
self::assertGreaterThan(0, $requestConfig[1]); // High priority
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(TenantResolver::class)]
|
||||
final class TenantResolverTest extends TestCase
|
||||
{
|
||||
private TenantRegistry $registry;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = $this->createMock(TenantRegistry::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsSubdomainFromHost(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local');
|
||||
|
||||
self::assertSame('ecole-alpha', $subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsSubdomainFromHostWithPort(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('ecole-alpha.classeo.local:8080');
|
||||
|
||||
self::assertSame('ecole-alpha', $subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForMainDomainWithoutSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForWwwSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('www.classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForApiSubdomain(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$subdomain = $resolver->extractSubdomain('api.classeo.local');
|
||||
|
||||
self::assertNull($subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesValidTenantFromHost(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
||||
$config = new TenantConfig(
|
||||
tenantId: $tenantId,
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_alpha',
|
||||
);
|
||||
|
||||
$this->registry->method('getBySubdomain')
|
||||
->with('ecole-alpha')
|
||||
->willReturn($config);
|
||||
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$resolved = $resolver->resolve('ecole-alpha.classeo.local');
|
||||
|
||||
self::assertTrue($tenantId->equals($resolved->tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForNonExistentTenant(): void
|
||||
{
|
||||
$this->registry->method('getBySubdomain')
|
||||
->with('ecole-inexistant')
|
||||
->willThrowException(TenantNotFoundException::withSubdomain('ecole-inexistant'));
|
||||
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve('ecole-inexistant.classeo.local');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionWhenNoSubdomainInHost(): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve('classeo.local');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('reservedSubdomainsProvider')]
|
||||
public function itRejectsReservedSubdomains(string $subdomain): void
|
||||
{
|
||||
$resolver = new TenantResolver($this->registry, 'classeo.local');
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
|
||||
$resolver->resolve("{$subdomain}.classeo.local");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function reservedSubdomainsProvider(): iterable
|
||||
{
|
||||
yield 'www' => ['www'];
|
||||
yield 'api' => ['api'];
|
||||
yield 'admin' => ['admin'];
|
||||
yield 'static' => ['static'];
|
||||
yield 'cdn' => ['cdn'];
|
||||
yield 'mail' => ['mail'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user