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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user