From 8c70ed132435a56a162609dad1d90da5789f28b2 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 10 Mar 2026 23:38:26 +0100 Subject: [PATCH] fix(tenant): route runtime traffic to tenant databases Wire Doctrine's default connection to the tenant database resolved from the subdomain for HTTP requests and tenant-scoped Messenger messages while keeping master-only services on the master connection. This removes the production inconsistency where demo data, migrations and tenant commands used the tenant database but the web runtime still read from master. --- backend/config/packages/doctrine.yaml | 17 +- backend/config/packages/messenger.yaml | 3 + backend/config/services.yaml | 32 ++++ .../Console/CreateTestUserCommand.php | 79 ++++++++- .../Messenger/TenantDatabaseMiddleware.php | 44 +++++ .../TenantMessageTenantIdResolver.php | 36 ++++ .../Doctrine/TenantAwareConnection.php | 167 ++++++++++++++++++ .../TenantDatabaseRequestSubscriber.php | 46 +++++ .../Tenant/TenantDatabaseSwitcher.php | 14 ++ .../Tenant/TenantMiddleware.php | 2 + .../TenantDatabaseMiddlewareTest.php | 125 +++++++++++++ .../TenantMessageTenantIdResolverTest.php | 57 ++++++ .../Doctrine/TenantAwareConnectionTest.php | 109 ++++++++++++ .../TenantDatabaseRequestSubscriberTest.php | 86 +++++++++ deploy/vps/generate-demo-data.sh | 26 +++ docs/DEPLOYMENT_VPS1.md | 1 + 16 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 backend/src/Shared/Infrastructure/Messenger/TenantDatabaseMiddleware.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolver.php create mode 100644 backend/src/Shared/Infrastructure/Persistence/Doctrine/TenantAwareConnection.php create mode 100644 backend/src/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriber.php create mode 100644 backend/src/Shared/Infrastructure/Tenant/TenantDatabaseSwitcher.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/TenantDatabaseMiddlewareTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolverTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Persistence/Doctrine/TenantAwareConnectionTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriberTest.php diff --git a/backend/config/packages/doctrine.yaml b/backend/config/packages/doctrine.yaml index 98b87ce..8fa13a9 100644 --- a/backend/config/packages/doctrine.yaml +++ b/backend/config/packages/doctrine.yaml @@ -1,7 +1,14 @@ doctrine: dbal: - url: '%env(resolve:DATABASE_URL)%' - profiling_collect_backtrace: '%kernel.debug%' + default_connection: default + connections: + default: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + wrapper_class: App\Shared\Infrastructure\Persistence\Doctrine\TenantAwareConnection + master: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' orm: validate_xml_mapping: true @@ -45,7 +52,11 @@ doctrine: when@test: doctrine: dbal: - dbname_suffix: '_test%env(default::TEST_TOKEN)%' + connections: + default: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + master: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' when@prod: doctrine: diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 1c1cdf3..3376c5d 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -11,6 +11,7 @@ framework: middleware: - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware + - App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware - doctrine_transaction query.bus: @@ -18,6 +19,7 @@ framework: middleware: - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware + - App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware event.bus: default_middleware: @@ -25,6 +27,7 @@ framework: middleware: - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware + - App\Shared\Infrastructure\Messenger\TenantDatabaseMiddleware - App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware - App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 2666f12..a749270 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -52,6 +52,12 @@ services: arguments: $baseDomain: '%tenant.base_domain%' + App\Shared\Infrastructure\Persistence\Doctrine\TenantAwareConnection: + alias: doctrine.dbal.default_connection + + App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher: + alias: doctrine.dbal.default_connection + # TenantRegistry est configuré par environnement : # - dev: config/packages/dev/tenant.yaml (tenants de test) # - prod: à configurer via admin ou env vars @@ -116,8 +122,21 @@ services: App\Shared\Infrastructure\Audit\AuditLogger: arguments: + $connection: '@doctrine.dbal.master_connection' $appSecret: '%env(APP_SECRET)%' + App\Shared\Infrastructure\Audit\AuditLogRepository: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\Shared\Infrastructure\Console\ArchiveAuditLogsCommand: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\Shared\Infrastructure\Console\ReviewFailedMessagesCommand: + arguments: + $connection: '@doctrine.dbal.master_connection' + # Audit log handlers (use AuditLogger to write to database) App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler: arguments: @@ -132,6 +151,18 @@ services: arguments: $userRepository: '@App\Administration\Domain\Repository\UserRepository' + App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\SuperAdmin\Application\Query\GetEstablishmentsMetrics\GetEstablishmentsMetricsHandler: + arguments: + $connection: '@doctrine.dbal.master_connection' + # Refresh Token Repository App\Administration\Domain\Repository\RefreshTokenRepository: alias: App\Administration\Infrastructure\Persistence\Redis\RedisRefreshTokenRepository @@ -346,6 +377,7 @@ services: # Infrastructure Health Checker - shared service for health checks (DRY) App\Shared\Infrastructure\Monitoring\InfrastructureHealthChecker: arguments: + $connection: '@doctrine.dbal.master_connection' $redisUrl: '%env(REDIS_URL)%' # Interface alias for InfrastructureHealthChecker (allows testing with stubs) diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php index 0a0652c..38abce2 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestUserCommand.php @@ -12,9 +12,11 @@ use App\Administration\Domain\Model\User\User; use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Repository\UserRepository; use App\Shared\Domain\Clock; +use App\Shared\Infrastructure\Tenant\TenantConfig; use App\Shared\Infrastructure\Tenant\TenantNotFoundException; use App\Shared\Infrastructure\Tenant\TenantRegistry; +use function getenv; use function sprintf; use Symfony\Component\Console\Attribute\AsCommand; @@ -23,6 +25,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Process\Process; /** * Creates an already-activated test user for E2E login tests. @@ -41,6 +45,8 @@ final class CreateTestUserCommand extends Command private readonly PasswordHasher $passwordHasher, private readonly TenantRegistry $tenantRegistry, private readonly Clock $clock, + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDir, ) { parent::__construct(); } @@ -51,8 +57,11 @@ final class CreateTestUserCommand extends Command ->addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'e2e-login@example.com') ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'TestPassword123') ->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT') + ->addOption('firstName', null, InputOption::VALUE_OPTIONAL, 'First name', '') + ->addOption('lastName', null, InputOption::VALUE_OPTIONAL, 'Last name', '') ->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test') - ->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha'); + ->addOption('tenant', null, InputOption::VALUE_OPTIONAL, 'Tenant subdomain (ecole-alpha, ecole-beta)', 'ecole-alpha') + ->addOption('internal-run', null, InputOption::VALUE_NONE, 'Internal option to create the test user inside the tenant database'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -66,10 +75,15 @@ final class CreateTestUserCommand extends Command /** @var string $roleOption */ $roleOption = $input->getOption('role'); $roleInput = strtoupper($roleOption); + /** @var string $firstName */ + $firstName = $input->getOption('firstName'); + /** @var string $lastName */ + $lastName = $input->getOption('lastName'); /** @var string $schoolName */ $schoolName = $input->getOption('school'); /** @var string $tenantSubdomain */ $tenantSubdomain = $input->getOption('tenant'); + $internalRun = $input->getOption('internal-run'); // Convert short role name to full Symfony role format $roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput; @@ -104,6 +118,19 @@ final class CreateTestUserCommand extends Command return Command::FAILURE; } + if (!$internalRun) { + return $this->relaunchAgainstTenantDatabase( + email: $email, + password: $password, + roleName: $roleName, + firstName: $firstName, + lastName: $lastName, + schoolName: $schoolName, + tenantConfig: $tenantConfig, + io: $io, + ); + } + $now = $this->clock->now(); // Check if user already exists @@ -140,6 +167,8 @@ final class CreateTestUserCommand extends Command hashedPassword: $hashedPassword, activatedAt: $now, consentementParental: null, + firstName: $firstName, + lastName: $lastName, ); $this->userRepository->save($user); @@ -161,4 +190,52 @@ final class CreateTestUserCommand extends Command return Command::SUCCESS; } + + private function relaunchAgainstTenantDatabase( + string $email, + string $password, + string $roleName, + string $firstName, + string $lastName, + string $schoolName, + TenantConfig $tenantConfig, + SymfonyStyle $io, + ): int { + $process = new Process( + command: [ + 'php', + 'bin/console', + 'app:dev:create-test-user', + '--email=' . $email, + '--password=' . $password, + '--role=' . $roleName, + '--firstName=' . $firstName, + '--lastName=' . $lastName, + '--school=' . $schoolName, + '--tenant=' . $tenantConfig->subdomain, + '--internal-run', + ], + cwd: $this->projectDir, + env: [ + ...getenv(), + 'DATABASE_URL' => $tenantConfig->databaseUrl, + ], + timeout: 300, + ); + + $process->run(static function (string $type, string $buffer) use ($io): void { + $io->write($buffer); + }); + + if ($process->isSuccessful()) { + return Command::SUCCESS; + } + + $io->error(sprintf( + 'Failed to create test user in tenant database "%s".', + $tenantConfig->subdomain, + )); + + return Command::FAILURE; + } } diff --git a/backend/src/Shared/Infrastructure/Messenger/TenantDatabaseMiddleware.php b/backend/src/Shared/Infrastructure/Messenger/TenantDatabaseMiddleware.php new file mode 100644 index 0000000..d222842 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/TenantDatabaseMiddleware.php @@ -0,0 +1,44 @@ +tenantIdResolver->resolve($envelope->getMessage()); + if ($tenantId === null) { + return $stack->next()->handle($envelope, $stack); + } + + $previousDatabaseUrl = $this->databaseSwitcher->currentDatabaseUrl(); + $tenantConfig = $this->tenantRegistry->getConfig(TenantId::fromString($tenantId)); + $this->databaseSwitcher->useTenantDatabase($tenantConfig->databaseUrl); + + try { + return $stack->next()->handle($envelope, $stack); + } finally { + if ($previousDatabaseUrl !== null) { + $this->databaseSwitcher->useTenantDatabase($previousDatabaseUrl); + } else { + $this->databaseSwitcher->useDefaultDatabase(); + } + } + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolver.php b/backend/src/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolver.php new file mode 100644 index 0000000..0fc726e --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolver.php @@ -0,0 +1,36 @@ + 'ibm_db2', + 'mssql' => 'pdo_sqlsrv', + 'mysql' => 'pdo_mysql', + 'mysql2' => 'pdo_mysql', + 'postgres' => 'pdo_pgsql', + 'postgresql' => 'pdo_pgsql', + 'pgsql' => 'pdo_pgsql', + 'sqlite' => 'pdo_sqlite', + 'sqlite3' => 'pdo_sqlite', + ]; + + /** @phpstan-var Params */ + private array $defaultConnectionParams; + + /** @phpstan-var Params */ + private array $currentConnectionParams; + + private ?string $currentDatabaseUrl = null; + + private ?AbstractPlatform $currentPlatform = null; + + private readonly DsnParser $dsnParser; + + /** + * @phpstan-param Params $params + */ + public function __construct( + #[SensitiveParameter] + array $params, + Driver $driver, + ?Configuration $config = null, + ) { + parent::__construct($params, $driver, $config); + + $this->defaultConnectionParams = $params; + $this->currentConnectionParams = $params; + $this->dsnParser = new DsnParser(self::URL_SCHEME_MAP); + } + + public function useTenantDatabase(string $databaseUrl): void + { + /** @phpstan-var Params $connectionParams */ + $connectionParams = array_merge($this->defaultConnectionParams, $this->dsnParser->parse($databaseUrl)); + + $this->applyConnectionParams($connectionParams, $databaseUrl); + } + + public function useDefaultDatabase(): void + { + $this->applyConnectionParams($this->defaultConnectionParams, null); + } + + public function currentDatabaseUrl(): ?string + { + return $this->currentDatabaseUrl; + } + + /** + * @phpstan-return Params + */ + public function getParams(): array + { + return $this->currentConnectionParams; + } + + public function getDatabasePlatform(): AbstractPlatform + { + if ($this->currentPlatform === null) { + $versionProvider = $this; + + $serverVersion = $this->currentConnectionParams['serverVersion'] ?? null; + if (is_string($serverVersion)) { + $versionProvider = new StaticServerVersionProvider($serverVersion); + } else { + $primaryConnection = $this->currentConnectionParams['primary'] ?? null; + if (is_array($primaryConnection)) { + $primaryServerVersion = $primaryConnection['serverVersion'] ?? null; + if (is_string($primaryServerVersion)) { + $versionProvider = new StaticServerVersionProvider($primaryServerVersion); + } + } + } + + $this->currentPlatform = $this->getDriver()->getDatabasePlatform($versionProvider); + } + + return $this->currentPlatform; + } + + public function close(): void + { + parent::close(); + $this->currentPlatform = null; + } + + protected function connect(): DriverConnection + { + if ($this->_conn !== null) { + return $this->_conn; + } + + try { + $connection = $this->_conn = $this->getDriver()->connect($this->currentConnectionParams); + } catch (Driver\Exception $e) { + throw $this->convertException($e); + } + + if (!$this->isAutoCommit()) { + $this->beginTransaction(); + } + + return $connection; + } + + /** + * @phpstan-param Params $params + */ + private function applyConnectionParams(array $params, ?string $databaseUrl): void + { + if ($this->currentConnectionParams === $params && $this->currentDatabaseUrl === $databaseUrl) { + return; + } + + if ($this->isConnected()) { + if ($this->isTransactionActive()) { + throw new RuntimeException('Cannot switch database while a transaction is active.'); + } + + $this->close(); + } + + $this->currentConnectionParams = $params; + $this->currentDatabaseUrl = $databaseUrl; + $this->currentPlatform = null; + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriber.php b/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriber.php new file mode 100644 index 0000000..b91509b --- /dev/null +++ b/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriber.php @@ -0,0 +1,46 @@ + ['onKernelRequest', 255], + KernelEvents::TERMINATE => 'onKernelTerminate', + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $tenant = $event->getRequest()->attributes->get('_tenant'); + if (!$tenant instanceof TenantConfig) { + $this->databaseSwitcher->useDefaultDatabase(); + + return; + } + + $this->databaseSwitcher->useTenantDatabase($tenant->databaseUrl); + } + + public function onKernelTerminate(): void + { + $this->databaseSwitcher->useDefaultDatabase(); + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseSwitcher.php b/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseSwitcher.php new file mode 100644 index 0000000..6497776 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Tenant/TenantDatabaseSwitcher.php @@ -0,0 +1,14 @@ +context->clear(); + $request = $event->getRequest(); $path = $request->getPathInfo(); $host = $request->getHost(); diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantDatabaseMiddlewareTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantDatabaseMiddlewareTest.php new file mode 100644 index 0000000..247244a --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantDatabaseMiddlewareTest.php @@ -0,0 +1,125 @@ +createMock(TenantRegistry::class); + $registry->expects(self::once()) + ->method('getConfig') + ->with(self::callback(static fn (TenantId $tenantId): bool => (string) $tenantId === 'a1b2c3d4-e5f6-7890-abcd-ef1234567890')) + ->willReturn($tenantConfig); + + $databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class); + $databaseSwitcher->expects(self::once()) + ->method('currentDatabaseUrl') + ->willReturn('postgresql://tenant:secret@db:5432/classeo_tenant_previous?serverVersion=18&charset=utf8'); + $databaseSwitcher->expects(self::exactly(2)) + ->method('useTenantDatabase') + ->with( + self::logicalOr( + self::equalTo($tenantConfig->databaseUrl), + self::equalTo('postgresql://tenant:secret@db:5432/classeo_tenant_previous?serverVersion=18&charset=utf8'), + ), + ); + + $middleware = new TenantDatabaseMiddleware( + $registry, + $databaseSwitcher, + new TenantMessageTenantIdResolver(), + ); + + $envelope = new Envelope($message); + $result = $middleware->handle($envelope, new TestStack( + new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $envelope; + } + }, + )); + + self::assertSame($envelope, $result); + } + + #[Test] + public function itLeavesTheCurrentConnectionUntouchedWhenTheMessageHasNoTenantId(): void + { + $registry = $this->createMock(TenantRegistry::class); + $registry->expects(self::never())->method('getConfig'); + + $databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class); + $databaseSwitcher->expects(self::never())->method('currentDatabaseUrl'); + $databaseSwitcher->expects(self::never())->method('useTenantDatabase'); + $databaseSwitcher->expects(self::never())->method('useDefaultDatabase'); + + $middleware = new TenantDatabaseMiddleware( + $registry, + $databaseSwitcher, + new TenantMessageTenantIdResolver(), + ); + + $message = new readonly class('batch-1') { + public function __construct( + public string $batchId, + ) { + } + }; + + $envelope = new Envelope($message); + $result = $middleware->handle($envelope, new TestStack( + new class implements MiddlewareInterface { + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return $envelope; + } + }, + )); + + self::assertSame($envelope, $result); + } +} + +final readonly class TestStack implements StackInterface +{ + public function __construct( + private MiddlewareInterface $next, + ) { + } + + public function next(): MiddlewareInterface + { + return $this->next; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolverTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolverTest.php new file mode 100644 index 0000000..0fe039b --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/TenantMessageTenantIdResolverTest.php @@ -0,0 +1,57 @@ +resolve($message)); + } + + #[Test] + public function itReadsTenantIdsFromValueObjects(): void + { + $resolver = new TenantMessageTenantIdResolver(); + $message = new readonly class(TenantId::fromString('b2c3d4e5-f6a7-8901-bcde-f12345678901')) { + public function __construct( + public TenantId $tenantId, + ) { + } + }; + + self::assertSame('b2c3d4e5-f6a7-8901-bcde-f12345678901', $resolver->resolve($message)); + } + + #[Test] + public function itReturnsNullWhenTheMessageIsNotTenantScoped(): void + { + $resolver = new TenantMessageTenantIdResolver(); + $message = new readonly class('') { + public function __construct( + public string $batchId, + ) { + } + }; + + self::assertNull($resolver->resolve($message)); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Persistence/Doctrine/TenantAwareConnectionTest.php b/backend/tests/Unit/Shared/Infrastructure/Persistence/Doctrine/TenantAwareConnectionTest.php new file mode 100644 index 0000000..1cc773e --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Persistence/Doctrine/TenantAwareConnectionTest.php @@ -0,0 +1,109 @@ +createMock(DriverConnection::class); + $driverConnection->method('getServerVersion')->willReturn('18.1'); + + $capturedParams = []; + $driver = $this->createDriver($driverConnection, $capturedParams); + + $connection = new TenantAwareConnection( + [ + 'driver' => 'pdo_pgsql', + 'host' => 'db', + 'port' => 5432, + 'user' => 'master', + 'password' => 'secret', + 'dbname' => 'classeo_master', + 'serverVersion' => '18', + ], + $driver, + ); + + self::assertSame('18.1', $connection->getServerVersion()); + self::assertSame('classeo_master', $capturedParams[0]['dbname']); + self::assertNull($connection->currentDatabaseUrl()); + + $tenantDatabaseUrl = 'postgresql://tenant:tenantpass@tenant-db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8'; + $connection->useTenantDatabase($tenantDatabaseUrl); + + self::assertSame('18.1', $connection->getServerVersion()); + self::assertSame('classeo_tenant_alpha', $capturedParams[1]['dbname']); + self::assertSame('tenant-db', $capturedParams[1]['host']); + self::assertSame('tenant', $capturedParams[1]['user']); + self::assertSame($tenantDatabaseUrl, $connection->currentDatabaseUrl()); + + $connection->useDefaultDatabase(); + + self::assertSame('18.1', $connection->getServerVersion()); + self::assertSame('classeo_master', $capturedParams[2]['dbname']); + self::assertNull($connection->currentDatabaseUrl()); + } + + #[Test] + public function itRejectsDatabaseSwitchesWhileATransactionIsOpen(): void + { + $driverConnection = $this->createMock(DriverConnection::class); + $driverConnection->method('getServerVersion')->willReturn('18.1'); + + $capturedParams = []; + $driver = $this->createDriver($driverConnection, $capturedParams); + $connection = new TenantAwareConnection( + [ + 'driver' => 'pdo_pgsql', + 'host' => 'db', + 'port' => 5432, + 'user' => 'master', + 'password' => 'secret', + 'dbname' => 'classeo_master', + 'serverVersion' => '18', + ], + $driver, + ); + + $connection->beginTransaction(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot switch database while a transaction is active.'); + + $connection->useTenantDatabase('postgresql://tenant:tenantpass@tenant-db:5432/classeo_tenant_alpha?serverVersion=18&charset=utf8'); + } + + /** + * @param array> $capturedParams + */ + private function createDriver(DriverConnection $driverConnection, array &$capturedParams): Driver + { + $driver = $this->createMock(Driver::class); + $driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); + $driver->method('getExceptionConverter')->willReturn($this->createMock(ExceptionConverter::class)); + $driver->method('connect')->willReturnCallback( + static function (array $params) use (&$capturedParams, $driverConnection): DriverConnection { + $capturedParams[] = $params; + + return $driverConnection; + }, + ); + + return $driver; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriberTest.php b/backend/tests/Unit/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriberTest.php new file mode 100644 index 0000000..35d9082 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Tenant/TenantDatabaseRequestSubscriberTest.php @@ -0,0 +1,86 @@ +createMock(TenantDatabaseSwitcher::class); + $databaseSwitcher->expects(self::once()) + ->method('useTenantDatabase') + ->with($tenantConfig->databaseUrl); + + $subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher); + $request = Request::create('https://ecole-alpha.classeo.local/api/users'); + $request->attributes->set('_tenant', $tenantConfig); + + $subscriber->onKernelRequest($this->createRequestEvent($request)); + } + + #[Test] + public function itFallsBackToTheDefaultDatabaseWhenNoTenantWasResolved(): void + { + $databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class); + $databaseSwitcher->expects(self::once()) + ->method('useDefaultDatabase'); + + $subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher); + $request = Request::create('https://classeo.local/api/docs'); + + $subscriber->onKernelRequest($this->createRequestEvent($request)); + } + + #[Test] + public function itResetsTheConnectionAfterTheRequest(): void + { + $databaseSwitcher = $this->createMock(TenantDatabaseSwitcher::class); + $databaseSwitcher->expects(self::once()) + ->method('useDefaultDatabase'); + + $subscriber = new TenantDatabaseRequestSubscriber($databaseSwitcher); + $subscriber->onKernelTerminate(); + } + + #[Test] + public function itRegistersItselfRightAfterTenantResolution(): void + { + $events = TenantDatabaseRequestSubscriber::getSubscribedEvents(); + + self::assertSame(['onKernelRequest', 255], $events[KernelEvents::REQUEST]); + self::assertSame('onKernelTerminate', $events[KernelEvents::TERMINATE]); + } + + private function createRequestEvent(Request $request): RequestEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + + return new RequestEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } +} diff --git a/deploy/vps/generate-demo-data.sh b/deploy/vps/generate-demo-data.sh index 0f7e5ac..89ecc2c 100755 --- a/deploy/vps/generate-demo-data.sh +++ b/deploy/vps/generate-demo-data.sh @@ -21,6 +21,8 @@ Options: --zone ZONE School zone: A, B or C. Default: B --period-type TYPE Academic period type: trimester or semester. Default: trimester + --target TARGET Where to write demo data: master or tenant. + Default: tenant --env-file PATH Override env file path. Default: ${ENV_FILE} --compose-file PATH Override compose file path. Default: ${COMPOSE_FILE} --service NAME Override PHP service name. Default: ${PHP_SERVICE} @@ -30,6 +32,7 @@ Examples: ./deploy/vps/generate-demo-data.sh ./deploy/vps/generate-demo-data.sh --password 'Demo2026!' ./deploy/vps/generate-demo-data.sh --tenant demo --school 'College de demo' + ./deploy/vps/generate-demo-data.sh --target master EOF } @@ -70,6 +73,7 @@ PASSWORD="DemoPassword123!" SCHOOL="" ZONE="B" PERIOD_TYPE="trimester" +TARGET="tenant" while [ "$#" -gt 0 ]; do case "$1" in @@ -113,6 +117,14 @@ while [ "$#" -gt 0 ]; do PERIOD_TYPE="${1#*=}" shift ;; + --target) + TARGET="${2:-}" + shift 2 + ;; + --target=*) + TARGET="${1#*=}" + shift + ;; --env-file) ENV_FILE="${2:-}" shift 2 @@ -172,6 +184,15 @@ if [ -z "${TENANT}" ]; then exit 1 fi +case "${TARGET}" in + master|tenant) + ;; + *) + echo "Invalid target: ${TARGET}. Expected 'master' or 'tenant'." >&2 + exit 1 + ;; +esac + if [ -z "${SCHOOL}" ] && [ -t 0 ]; then SCHOOL=$(prompt_with_default "School name (optional)" "") fi @@ -188,6 +209,10 @@ COMMAND=( "--period-type=${PERIOD_TYPE}" ) +if [ "${TARGET}" = "master" ]; then + COMMAND+=("--internal-run") +fi + if [ -n "${SCHOOL}" ]; then COMMAND+=("--school=${SCHOOL}") fi @@ -195,6 +220,7 @@ fi echo "Running demo data generator for tenant: ${TENANT}" echo "Compose file: ${COMPOSE_FILE}" echo "Env file: ${ENV_FILE}" +echo "Target database: ${TARGET}" echo "${COMMAND[@]}" diff --git a/docs/DEPLOYMENT_VPS1.md b/docs/DEPLOYMENT_VPS1.md index 92db8ba..4a1078a 100644 --- a/docs/DEPLOYMENT_VPS1.md +++ b/docs/DEPLOYMENT_VPS1.md @@ -317,6 +317,7 @@ Exemples : ./deploy/vps/generate-demo-data.sh --password 'Demo2026!' ./deploy/vps/generate-demo-data.sh --school 'College de demo' ./deploy/vps/generate-demo-data.sh --tenant demo --zone B --period-type trimester +./deploy/vps/generate-demo-data.sh --target master ``` La commande utilise un mot de passe commun pour tous les comptes, avec une valeur par defaut si tu n'en fournis pas, et affiche tous les comptes crees.