From 531548c65f9c70e32fde974af49914e631556ef1 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 8 Apr 2026 13:55:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Provisionner=20automatiquement=20un=20n?= =?UTF-8?q?ouvel=20=C3=A9tablissement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure. --- .../sprint-status.yaml | 2 +- backend/composer.json | 1 + backend/composer.lock | 69 ++++- backend/config/packages/messenger.yaml | 2 + backend/config/packages/prod/tenant.yaml | 25 +- backend/config/services.yaml | 17 ++ .../Tenant/DoctrineTenantRegistry.php | 132 ++++++++++ .../CreateEstablishmentHandler.php | 11 +- .../CreateEstablishmentResult.php | 17 -- .../ProvisionEstablishmentCommand.php | 25 ++ .../Application/Port/TenantProvisioner.php | 20 ++ .../Domain/Event/EtablissementCree.php | 1 + .../Model/Establishment/Establishment.php | 9 +- .../Establishment/EstablishmentStatus.php | 1 + .../CreateEstablishmentProcessor.php | 26 +- .../DatabaseTenantProvisioner.php | 27 ++ .../ProvisionEstablishmentHandler.php | 180 +++++++++++++ .../Provisioning/TenantDatabaseCreator.php | 76 ++++++ .../Provisioning/TenantMigrator.php | 78 ++++++ .../Audit/AuditTrailFunctionalTest.php | 185 ++++++++++++++ .../Tenant/DoctrineTenantRegistryTest.php | 119 +++++++++ .../CreateEstablishmentHandlerTest.php | 18 +- .../GetEstablishmentsHandlerTest.php | 4 +- .../Model/Establishment/EstablishmentTest.php | 18 +- .../CreateEstablishmentProcessorTest.php | 24 +- .../EstablishmentCollectionProviderTest.php | 3 +- .../DatabaseTenantProvisionerTest.php | 72 ++++++ .../ProvisionEstablishmentHandlerTest.php | 236 ++++++++++++++++++ .../ProvisioningIntegrationTest.php | 166 ++++++++++++ .../Provisioning/SpyDatabaseSwitcher.php | 32 +++ deploy/vps/Caddyfile | 12 +- frontend/e2e/role-assignment.spec.ts | 167 +++++++++++++ frontend/e2e/super-admin-provisioning.spec.ts | 205 +++++++++++++++ .../super-admin/establishments/+page.svelte | 17 +- 34 files changed, 1928 insertions(+), 69 deletions(-) create mode 100644 backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php delete mode 100644 backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php create mode 100644 backend/src/SuperAdmin/Application/Command/ProvisionEstablishment/ProvisionEstablishmentCommand.php create mode 100644 backend/src/SuperAdmin/Application/Port/TenantProvisioner.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php create mode 100644 backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php create mode 100644 frontend/e2e/role-assignment.spec.ts create mode 100644 frontend/e2e/super-admin-provisioning.spec.ts diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a6924e9..4c616f3 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -74,7 +74,7 @@ development_status: 2-12b-optimistic-update-pages-admin: done 2-13-personnalisation-visuelle-etablissement: done 2-15-organisation-sections-dashboard-admin: done - 2-17-provisioning-automatique-etablissements: ready-for-dev # Tâches post-MVP différées de 2-10 + 2-17-provisioning-automatique-etablissements: in-progress # Tâches post-MVP différées de 2-10 epic-2-retrospective: done # Epic 3: Import & Onboarding (5 stories) diff --git a/backend/composer.json b/backend/composer.json index 261dbfa..7002ac1 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -26,6 +26,7 @@ "symfony/console": "^8.0", "symfony/doctrine-messenger": "^8.0", "symfony/dotenv": "^8.0", + "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "^8.0", "symfony/html-sanitizer": "8.0.*", diff --git a/backend/composer.lock b/backend/composer.lock index 57b067c..e4c313e 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "92b9472c96a59c314d96372c4094f185", + "content-hash": "a4b309e6d9d273339a5154a64a5f7361", "packages": [ { "name": "api-platform/core", @@ -4672,6 +4672,73 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/expression-language", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/expression-language.git", + "reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b", + "reference": "b2a5fd3b7331ae10cc0ed75a28d64b25b67d2c7b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/cache": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ExpressionLanguage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an engine that can compile and evaluate expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/expression-language/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/filesystem", "version": "v8.0.1", diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 3376c5d..0249116 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -64,3 +64,5 @@ framework: # Import élèves/enseignants → async (batch processing, peut être long) App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async + # Provisioning établissement → async (création BDD, migrations, premier admin) + App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand: async diff --git a/backend/config/packages/prod/tenant.yaml b/backend/config/packages/prod/tenant.yaml index dbff0af..9e2d852 100644 --- a/backend/config/packages/prod/tenant.yaml +++ b/backend/config/packages/prod/tenant.yaml @@ -1,19 +1,14 @@ -# Configuration des tenants en production +# Tenants en production : résolution dynamique depuis la base establishments # -# En production, les tenants peuvent être configurés de deux façons : -# 1. Via la variable d'environnement TENANT_CONFIGS (JSON) -# 2. Via une implémentation DatabaseTenantRegistry (à implémenter) -# -# Pour l'instant, on utilise InMemoryTenantRegistry avec configuration env. -# Si aucun tenant n'est configuré, toutes les requêtes retourneront 404. - -parameters: - # Format JSON attendu: [{"tenantId":"uuid","subdomain":"ecole","databaseUrl":"postgres://..."}] - tenant.prod_configs_json: '%env(default::TENANT_CONFIGS)%' +# Le DoctrineTenantRegistry interroge la table establishments sur la base master. +# Les nouveaux établissements sont immédiatement accessibles via leur sous-domaine +# sans redémarrage de l'application. services: - App\Shared\Infrastructure\Tenant\TenantRegistry: - class: App\Shared\Infrastructure\Tenant\InMemoryTenantRegistry - factory: ['@App\Shared\Infrastructure\Tenant\TenantRegistryFactory', 'createFromEnv'] + App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry: arguments: - $configsJson: '%tenant.prod_configs_json%' + $connection: '@doctrine.dbal.master_connection' + $masterDatabaseUrl: '%env(DATABASE_URL)%' + + App\Shared\Infrastructure\Tenant\TenantRegistry: + alias: App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 6f43968..f6a85b1 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -333,6 +333,23 @@ services: App\SuperAdmin\Domain\Repository\EstablishmentRepository: alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository + # Provisioning (Story 2.17 - Provisioning automatique) + App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator: + arguments: + $connection: '@doctrine.dbal.master_connection' + + App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator: + arguments: + $projectDir: '%kernel.project_dir%' + $masterDatabaseUrl: '%env(DATABASE_URL)%' + + App\SuperAdmin\Application\Port\TenantProvisioner: + alias: App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner + + App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler: + arguments: + $masterDatabaseUrl: '%env(DATABASE_URL)%' + # School Calendar Repository (Story 2.11 - Calendrier scolaire) App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolCalendarRepository diff --git a/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php b/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php new file mode 100644 index 0000000..5e64a06 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Tenant/DoctrineTenantRegistry.php @@ -0,0 +1,132 @@ +|null Indexed by tenant ID */ + private ?array $byId = null; + + /** @var array|null Indexed by subdomain */ + private ?array $bySubdomain = null; + + public function __construct( + private readonly Connection $connection, + private readonly string $masterDatabaseUrl, + ) { + } + + #[Override] + public function getConfig(TenantId $tenantId): TenantConfig + { + $this->ensureLoaded(); + + $key = (string) $tenantId; + + if (!isset($this->byId[$key])) { + throw TenantNotFoundException::withId($tenantId); + } + + return $this->byId[$key]; + } + + #[Override] + public function getBySubdomain(string $subdomain): TenantConfig + { + $this->ensureLoaded(); + + if (!isset($this->bySubdomain[$subdomain])) { + throw TenantNotFoundException::withSubdomain($subdomain); + } + + return $this->bySubdomain[$subdomain]; + } + + #[Override] + public function exists(string $subdomain): bool + { + $this->ensureLoaded(); + + return isset($this->bySubdomain[$subdomain]); + } + + #[Override] + public function getAllConfigs(): array + { + $this->ensureLoaded(); + + /** @var array $byId */ + $byId = $this->byId; + + return array_values($byId); + } + + #[Override] + public function reset(): void + { + $this->byId = null; + $this->bySubdomain = null; + } + + private function ensureLoaded(): void + { + if ($this->byId !== null) { + return; + } + + $this->byId = []; + $this->bySubdomain = []; + + /** @var array $rows */ + $rows = $this->connection->fetchAllAssociative( + "SELECT tenant_id, subdomain, database_name FROM establishments WHERE status = 'active'", + ); + + foreach ($rows as $row) { + $config = new TenantConfig( + tenantId: TenantId::fromString($row['tenant_id']), + subdomain: $row['subdomain'], + databaseUrl: $this->buildDatabaseUrl($row['database_name']), + ); + + $this->byId[$row['tenant_id']] = $config; + $this->bySubdomain[$row['subdomain']] = $config; + } + } + + private function buildDatabaseUrl(string $databaseName): string + { + $parsed = parse_url($this->masterDatabaseUrl); + + $scheme = $parsed['scheme'] ?? 'postgresql'; + $user = $parsed['user'] ?? ''; + $pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : ''; + $host = $parsed['host'] ?? 'localhost'; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + + return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query); + } +} diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php index 56a378b..0681342 100644 --- a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php +++ b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php @@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler ) { } - public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult + public function __invoke(CreateEstablishmentCommand $command): Establishment { $establishment = Establishment::creer( name: $command->name, subdomain: $command->subdomain, + adminEmail: $command->adminEmail, createdBy: SuperAdminId::fromString($command->superAdminId), createdAt: $this->clock->now(), ); $this->establishmentRepository->save($establishment); - return new CreateEstablishmentResult( - establishmentId: (string) $establishment->id, - tenantId: (string) $establishment->tenantId, - name: $establishment->name, - subdomain: $establishment->subdomain, - databaseName: $establishment->databaseName, - ); + return $establishment; } } diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php deleted file mode 100644 index fe24055..0000000 --- a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php +++ /dev/null @@ -1,17 +0,0 @@ -tenantId, name: $name, subdomain: $subdomain, + adminEmail: $adminEmail, occurredOn: $createdAt, )); return $establishment; } + public function activer(): void + { + $this->status = EstablishmentStatus::ACTIF; + } + public function desactiver(DateTimeImmutable $at): void { if ($this->status !== EstablishmentStatus::ACTIF) { diff --git a/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php index 356404a..52054b5 100644 --- a/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php +++ b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php @@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment; enum EstablishmentStatus: string { + case PROVISIONING = 'provisioning'; case ACTIF = 'active'; case INACTIF = 'inactive'; } diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php b/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php index 6dfe858..c053c75 100644 --- a/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php +++ b/backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php @@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; +use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand; use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource; use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin; use Override; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Messenger\MessageBusInterface; /** * @implements ProcessorInterface @@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface public function __construct( private CreateEstablishmentHandler $handler, private Security $security, + private MessageBusInterface $commandBus, ) { } @@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface /** @var SecuritySuperAdmin $user */ $user = $this->security->getUser(); - $result = ($this->handler)(new CreateEstablishmentCommand( + $establishment = ($this->handler)(new CreateEstablishmentCommand( name: $data->name, subdomain: $data->subdomain, adminEmail: $data->adminEmail, superAdminId: $user->superAdminId(), )); + $this->commandBus->dispatch(new ProvisionEstablishmentCommand( + establishmentId: (string) $establishment->id, + establishmentTenantId: (string) $establishment->tenantId, + databaseName: $establishment->databaseName, + subdomain: $establishment->subdomain, + adminEmail: $data->adminEmail, + establishmentName: $establishment->name, + )); + $resource = new EstablishmentResource(); - $resource->id = $result->establishmentId; - $resource->tenantId = $result->tenantId; - $resource->name = $result->name; - $resource->subdomain = $result->subdomain; - $resource->databaseName = $result->databaseName; - $resource->status = 'active'; + $resource->id = (string) $establishment->id; + $resource->tenantId = (string) $establishment->tenantId; + $resource->name = $establishment->name; + $resource->subdomain = $establishment->subdomain; + $resource->databaseName = $establishment->databaseName; + $resource->status = $establishment->status->value; return $resource; } diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php new file mode 100644 index 0000000..722efa0 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisioner.php @@ -0,0 +1,27 @@ +databaseCreator->create($databaseName); + $this->migrator->migrate($databaseName); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php new file mode 100644 index 0000000..46bdfc4 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandler.php @@ -0,0 +1,180 @@ +logger->info('Starting establishment provisioning.', [ + 'establishment' => $command->establishmentId, + 'subdomain' => $command->subdomain, + ]); + + $this->tenantProvisioner->provision($command->databaseName); + + // Create admin user on the tenant database, collect events without dispatching + $pendingEvents = $this->createFirstAdminOnTenantDb($command); + + // Activate establishment on master DB so the tenant becomes resolvable + $this->activateEstablishment($command->establishmentId); + + // Now dispatch events — the tenant is active and resolvable by the middleware + foreach ($pendingEvents as $event) { + $this->eventBus->dispatch($event); + } + + $this->logger->info('Establishment provisioning completed.', [ + 'establishment' => $command->establishmentId, + 'subdomain' => $command->subdomain, + 'adminEmail' => $command->adminEmail, + ]); + } + + /** + * @return DomainEvent[] + */ + private function createFirstAdminOnTenantDb(ProvisionEstablishmentCommand $command): array + { + $tenantDatabaseUrl = $this->buildTenantDatabaseUrl($command->databaseName); + $this->databaseSwitcher->useTenantDatabase($tenantDatabaseUrl); + + try { + return $this->createFirstAdmin($command); + } catch (Throwable $e) { + $this->restoreDefaultDatabase(); + + throw $e; + } finally { + $this->restoreDefaultDatabase(); + } + } + + /** + * @return DomainEvent[] + */ + private function createFirstAdmin(ProvisionEstablishmentCommand $command): array + { + try { + $user = ($this->inviteUserHandler)(new InviteUserCommand( + tenantId: $command->establishmentTenantId, + schoolName: $command->establishmentName, + email: $command->adminEmail, + role: Role::ADMIN->value, + firstName: 'Administrateur', + lastName: $command->establishmentName, + )); + + return $user->pullDomainEvents(); + } catch (EmailDejaUtiliseeException) { + $this->logger->info('Admin already exists, re-sending invitation.', [ + 'email' => $command->adminEmail, + ]); + + return $this->resendInvitation($command); + } + } + + /** + * @return DomainEvent[] + */ + private function resendInvitation(ProvisionEstablishmentCommand $command): array + { + $existingUser = $this->userRepository->findByEmail( + new Email($command->adminEmail), + TenantId::fromString($command->establishmentTenantId), + ); + + if ($existingUser === null) { + return []; + } + + $existingUser->renvoyerInvitation($this->clock->now()); + $this->userRepository->save($existingUser); + + return $existingUser->pullDomainEvents(); + } + + private function activateEstablishment(string $establishmentId): void + { + $establishment = $this->establishmentRepository->get( + EstablishmentId::fromString($establishmentId), + ); + $establishment->activer(); + $this->establishmentRepository->save($establishment); + } + + private function restoreDefaultDatabase(): void + { + try { + $this->databaseSwitcher->useDefaultDatabase(); + } catch (Throwable $e) { + $this->logger->error('Failed to restore default database connection.', [ + 'error' => $e->getMessage(), + ]); + } + } + + private function buildTenantDatabaseUrl(string $databaseName): string + { + $parsed = parse_url($this->masterDatabaseUrl); + + $scheme = $parsed['scheme'] ?? 'postgresql'; + $user = $parsed['user'] ?? ''; + $pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : ''; + $host = $parsed['host'] ?? 'localhost'; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + + return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php new file mode 100644 index 0000000..3ffb740 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantDatabaseCreator.php @@ -0,0 +1,76 @@ +connection->fetchOne( + 'SELECT 1 FROM pg_database WHERE datname = :name', + ['name' => $databaseName], + ); + + if ($exists !== false) { + $this->logger->info('Tenant database already exists, skipping creation.', [ + 'database' => $databaseName, + ]); + + return; + } + + $this->connection->executeStatement(sprintf( + "CREATE DATABASE %s WITH OWNER = %s ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8'", + $this->quoteIdentifier($databaseName), + $this->quoteIdentifier($this->databaseUser), + )); + + $this->logger->info('Tenant database created.', ['database' => $databaseName]); + } catch (Throwable $e) { + throw new RuntimeException( + sprintf('Failed to create tenant database "%s": %s', $databaseName, $e->getMessage()), + previous: $e, + ); + } + } + + private function quoteIdentifier(string $identifier): string + { + return '"' . str_replace('"', '""', $identifier) . '"'; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php new file mode 100644 index 0000000..14bd21d --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Provisioning/TenantMigrator.php @@ -0,0 +1,78 @@ +buildDatabaseUrl($databaseName); + + $process = new Process( + command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'], + cwd: $this->projectDir, + env: [ + ...getenv(), + 'DATABASE_URL' => $databaseUrl, + ], + timeout: 300, + ); + + $this->logger->info('Running migrations for tenant database.', ['database' => $databaseName]); + + $process->run(); + + if (!$process->isSuccessful()) { + throw new RuntimeException(sprintf( + 'Migration failed for tenant database "%s": %s', + $databaseName, + $process->getErrorOutput(), + )); + } + + $this->logger->info('Migrations completed for tenant database.', ['database' => $databaseName]); + } + + private function buildDatabaseUrl(string $databaseName): string + { + $parsed = parse_url($this->masterDatabaseUrl); + + $scheme = $parsed['scheme'] ?? 'postgresql'; + $user = $parsed['user'] ?? ''; + $pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : ''; + $host = $parsed['host'] ?? 'localhost'; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + + return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query); + } +} diff --git a/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php b/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php new file mode 100644 index 0000000..f7ea95f --- /dev/null +++ b/backend/tests/Functional/Shared/Infrastructure/Audit/AuditTrailFunctionalTest.php @@ -0,0 +1,185 @@ +connection = $container->get(Connection::class); + + /* @var AuditLogger $auditLogger */ + $this->auditLogger = $container->get(AuditLogger::class); + } + + #[Test] + public function logAuthenticationWritesEntryToAuditLogTable(): void + { + $userId = Uuid::uuid4(); + + $this->auditLogger->logAuthentication( + eventType: 'ConnexionReussie', + userId: $userId, + payload: [ + 'email_hash' => hash('sha256', 'test@example.com'), + 'result' => 'success', + 'method' => 'password', + ], + ); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$userId->toString(), 'ConnexionReussie'], + ); + + self::assertNotFalse($entry, 'Audit log entry should exist after logAuthentication'); + self::assertSame('User', $entry['aggregate_type']); + self::assertSame($userId->toString(), $entry['aggregate_id']); + self::assertSame('ConnexionReussie', $entry['event_type']); + + $payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('success', $payload['result']); + self::assertSame('password', $payload['method']); + self::assertArrayHasKey('email_hash', $payload); + } + + #[Test] + public function logAuthenticationIncludesMetadataWithTimestamp(): void + { + $userId = Uuid::uuid4(); + + $this->auditLogger->logAuthentication( + eventType: 'ConnexionReussie', + userId: $userId, + payload: ['result' => 'success'], + ); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? ORDER BY occurred_at DESC LIMIT 1', + [$userId->toString()], + ); + + self::assertNotFalse($entry); + self::assertNotEmpty($entry['occurred_at'], 'Audit entry must have a timestamp'); + + $metadata = json_decode($entry['metadata'], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($metadata); + } + + #[Test] + public function logFailedAuthenticationWritesWithNullUserId(): void + { + $this->auditLogger->logAuthentication( + eventType: 'ConnexionEchouee', + userId: null, + payload: [ + 'email_hash' => hash('sha256', 'unknown@example.com'), + 'result' => 'failure', + 'reason' => 'invalid_credentials', + ], + ); + + $entry = $this->connection->fetchAssociative( + "SELECT * FROM audit_log WHERE event_type = 'ConnexionEchouee' ORDER BY occurred_at DESC LIMIT 1", + ); + + self::assertNotFalse($entry, 'Failed login audit entry should exist'); + self::assertNull($entry['aggregate_id'], 'Failed login should have null user ID'); + self::assertSame('User', $entry['aggregate_type']); + + $payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('failure', $payload['result']); + self::assertSame('invalid_credentials', $payload['reason']); + } + + #[Test] + public function logDataChangeWritesOldAndNewValues(): void + { + $aggregateId = Uuid::uuid4(); + + $this->auditLogger->logDataChange( + aggregateType: 'Grade', + aggregateId: $aggregateId, + eventType: 'GradeModified', + oldValues: ['value' => 14.0], + newValues: ['value' => 16.0], + reason: 'Correction erreur de saisie', + ); + + $entry = $this->connection->fetchAssociative( + 'SELECT * FROM audit_log WHERE aggregate_id = ? AND event_type = ? ORDER BY occurred_at DESC LIMIT 1', + [$aggregateId->toString(), 'GradeModified'], + ); + + self::assertNotFalse($entry); + self::assertSame('Grade', $entry['aggregate_type']); + + $payload = json_decode($entry['payload'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame(['value' => 14.0], $payload['old_values']); + self::assertSame(['value' => 16.0], $payload['new_values']); + self::assertSame('Correction erreur de saisie', $payload['reason']); + } + + #[Test] + public function auditLogEntriesAreAppendOnly(): void + { + $userId = Uuid::uuid4(); + + $this->auditLogger->logAuthentication( + eventType: 'ConnexionReussie', + userId: $userId, + payload: ['result' => 'success'], + ); + + $countBefore = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?', + [$userId->toString()], + ); + + self::assertSame(1, $countBefore); + + // Log a second event for the same user + $this->auditLogger->logAuthentication( + eventType: 'ConnexionReussie', + userId: $userId, + payload: ['result' => 'success'], + ); + + $countAfter = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE aggregate_id = ?', + [$userId->toString()], + ); + + // Both entries should exist (append-only, no overwrite) + self::assertSame(2, $countAfter, 'Audit log must be append-only — both entries should exist'); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php b/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php new file mode 100644 index 0000000..e777c48 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Tenant/DoctrineTenantRegistryTest.php @@ -0,0 +1,119 @@ +registryWith([ + ['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME], + ]); + + $config = $registry->getBySubdomain(self::SUBDOMAIN); + + self::assertSame(self::SUBDOMAIN, $config->subdomain); + self::assertSame(self::TENANT_ID, (string) $config->tenantId); + self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl); + } + + #[Test] + public function itResolvesConfigByTenantId(): void + { + $registry = $this->registryWith([ + ['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME], + ]); + + $config = $registry->getConfig(TenantId::fromString(self::TENANT_ID)); + + self::assertSame(self::SUBDOMAIN, $config->subdomain); + } + + #[Test] + public function itThrowsForUnknownSubdomain(): void + { + $registry = $this->registryWith([]); + + $this->expectException(TenantNotFoundException::class); + $registry->getBySubdomain('inexistant'); + } + + #[Test] + public function itThrowsForUnknownTenantId(): void + { + $registry = $this->registryWith([]); + + $this->expectException(TenantNotFoundException::class); + $registry->getConfig(TenantId::fromString(self::TENANT_ID)); + } + + #[Test] + public function itChecksExistence(): void + { + $registry = $this->registryWith([ + ['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME], + ]); + + self::assertTrue($registry->exists(self::SUBDOMAIN)); + self::assertFalse($registry->exists('inexistant')); + } + + #[Test] + public function itReturnsAllConfigs(): void + { + $registry = $this->registryWith([ + ['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME], + ['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'], + ]); + + $configs = $registry->getAllConfigs(); + + self::assertCount(2, $configs); + } + + #[Test] + public function itQueriesDatabaseOnlyOnce(): void + { + $connection = $this->createMock(Connection::class); + $connection->expects(self::once()) + ->method('fetchAllAssociative') + ->willReturn([ + ['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME], + ]); + + $registry = new DoctrineTenantRegistry($connection, self::MASTER_URL); + + $registry->getBySubdomain(self::SUBDOMAIN); + $registry->getConfig(TenantId::fromString(self::TENANT_ID)); + $registry->exists(self::SUBDOMAIN); + $registry->getAllConfigs(); + } + + /** + * @param array $rows + */ + private function registryWith(array $rows): DoctrineTenantRegistry + { + $connection = $this->createMock(Connection::class); + $connection->method('fetchAllAssociative')->willReturn($rows); + + return new DoctrineTenantRegistry($connection, self::MASTER_URL); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php index 8345d96..626677d 100644 --- a/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php +++ b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php @@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase } #[Test] - public function createsEstablishmentAndReturnsResult(): void + public function createsEstablishmentAndReturnsIt(): void { $command = new CreateEstablishmentCommand( name: 'École Alpha', @@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase superAdminId: self::SUPER_ADMIN_ID, ); - $result = ($this->handler)($command); + $establishment = ($this->handler)($command); - self::assertNotEmpty($result->establishmentId); - self::assertNotEmpty($result->tenantId); - self::assertSame('École Alpha', $result->name); - self::assertSame('ecole-alpha', $result->subdomain); - self::assertStringStartsWith('classeo_tenant_', $result->databaseName); + self::assertNotEmpty((string) $establishment->id); + self::assertNotEmpty((string) $establishment->tenantId); + self::assertSame('École Alpha', $establishment->name); + self::assertSame('ecole-alpha', $establishment->subdomain); + self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); } #[Test] @@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase superAdminId: self::SUPER_ADMIN_ID, ); - $result = ($this->handler)($command); + $establishment = ($this->handler)($command); $establishments = $this->repository->findAll(); self::assertCount(1, $establishments); - self::assertSame($result->establishmentId, (string) $establishments[0]->id); + self::assertSame((string) $establishment->id, (string) $establishments[0]->id); } } diff --git a/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php index 0252806..1dba9f0 100644 --- a/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php +++ b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php @@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase $this->repository->save(Establishment::creer( name: 'École Alpha', subdomain: 'ecole-alpha', + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), )); @@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase $this->repository->save(Establishment::creer( name: 'École Beta', subdomain: 'ecole-beta', + adminEmail: 'admin@ecole-beta.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 11:00:00'), )); @@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase self::assertCount(2, $result); self::assertSame('École Alpha', $result[0]->name); self::assertSame('ecole-alpha', $result[0]->subdomain); - self::assertSame('active', $result[0]->status); + self::assertSame('provisioning', $result[0]->status); } } diff --git a/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php index f0951a9..bc7b52c 100644 --- a/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php +++ b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php @@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase private const string SUBDOMAIN = 'ecole-alpha'; #[Test] - public function creerCreatesActiveEstablishment(): void + public function creerCreatesProvisioningEstablishment(): void { $establishment = $this->createEstablishment(); - self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status); self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name); self::assertSame(self::SUBDOMAIN, $establishment->subdomain); self::assertNull($establishment->lastActivityAt); @@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); } + #[Test] + public function activerChangesStatusToActif(): void + { + $establishment = $this->createEstablishment(); + + self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status); + $establishment->activer(); + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + } + #[Test] public function desactiverChangesStatusToInactif(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); @@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase public function desactiverRecordsEtablissementDesactiveEvent(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->pullDomainEvents(); // Clear creation event $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); @@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase public function desactiverThrowsWhenAlreadyInactive(): void { $establishment = $this->createEstablishment(); + $establishment->activer(); $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); $this->expectException(EstablishmentDejaInactifException::class); @@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase return Establishment::creer( name: self::ESTABLISHMENT_NAME, subdomain: self::SUBDOMAIN, + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), ); diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php index f8e6c34..878a754 100644 --- a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor; use ApiPlatform\Metadata\Post; use App\Shared\Domain\Clock; use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler; +use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand; use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId; use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor; use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource; @@ -16,13 +17,15 @@ use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; final class CreateEstablishmentProcessorTest extends TestCase { private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001'; #[Test] - public function processCreatesEstablishmentAndReturnsResource(): void + public function processCreatesEstablishmentAndDispatchesProvisioning(): void { $repository = new InMemoryEstablishmentRepository(); $clock = new class implements Clock { @@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase $security = $this->createMock(Security::class); $security->method('getUser')->willReturn($securityUser); - $processor = new CreateEstablishmentProcessor($handler, $security); + $dispatched = []; + $commandBus = $this->createMock(MessageBusInterface::class); + $commandBus->method('dispatch') + ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { + $dispatched[] = $message; + + return new Envelope($message); + }); + + $processor = new CreateEstablishmentProcessor($handler, $security, $commandBus); $input = new EstablishmentResource(); $input->name = 'École Gamma'; @@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase self::assertNotNull($result->tenantId); self::assertSame('École Gamma', $result->name); self::assertSame('ecole-gamma', $result->subdomain); - self::assertSame('active', $result->status); + self::assertSame('provisioning', $result->status); + + self::assertCount(1, $dispatched); + self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]); + self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail); + self::assertSame('ecole-gamma', $dispatched[0]->subdomain); + self::assertSame('École Gamma', $dispatched[0]->establishmentName); } } diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php index 94771e2..9b0131b 100644 --- a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php @@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase $repository->save(Establishment::creer( name: 'École Alpha', subdomain: 'ecole-alpha', + adminEmail: 'admin@ecole-alpha.fr', createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), )); @@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase self::assertCount(1, $result); self::assertSame('École Alpha', $result[0]->name); self::assertSame('ecole-alpha', $result[0]->subdomain); - self::assertSame('active', $result[0]->status); + self::assertSame('provisioning', $result[0]->status); } } diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php new file mode 100644 index 0000000..d283664 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/DatabaseTenantProvisionerTest.php @@ -0,0 +1,72 @@ +createMock(Connection::class); + $connection->method('fetchOne')->willReturn(false); + $connection->method('executeStatement')->willReturnCallback( + static function () use (&$steps): int { + $steps[] = 'create'; + + return 1; + }, + ); + + $creator = new TenantDatabaseCreator($connection, new NullLogger()); + + // TenantMigrator is final — we wrap via the TenantProvisioner interface + // to verify the creator is called. Migration subprocess cannot be tested unitarily. + $provisioner = new class($creator, $steps) implements TenantProvisioner { + /** @param string[] $steps */ + public function __construct( + private readonly TenantDatabaseCreator $creator, + private array &$steps, + ) { + } + + public function provision(string $databaseName): void + { + $this->creator->create($databaseName); + $this->steps[] = 'migrate'; + } + }; + + $provisioner->provision('classeo_tenant_test'); + + self::assertSame(['create', 'migrate'], $steps); + } + + #[Test] + public function itPropagatesCreationFailure(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused')); + + $creator = new TenantDatabaseCreator($connection, new NullLogger()); + $migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger()); + + $provisioner = new DatabaseTenantProvisioner($creator, $migrator); + + $this->expectException(RuntimeException::class); + $provisioner->provision('classeo_tenant_test'); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php new file mode 100644 index 0000000..e5283d7 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisionEstablishmentHandlerTest.php @@ -0,0 +1,236 @@ +createMock(TenantProvisioner::class); + $provisioner->expects(self::once()) + ->method('provision') + ->with('classeo_tenant_abc123'); + + $handler = $this->buildHandler(provisioner: $provisioner); + $handler($this->command()); + } + + #[Test] + public function itCreatesAdminUser(): void + { + $userRepository = new InMemoryUserRepository(); + + $handler = $this->buildHandler(userRepository: $userRepository); + $handler($this->command()); + + $users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID)); + self::assertCount(1, $users); + self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email); + } + + #[Test] + public function itDispatchesInvitationEvent(): void + { + $dispatched = []; + $eventBus = $this->spyEventBus($dispatched); + + $handler = $this->buildHandler(eventBus: $eventBus); + $handler($this->command()); + + self::assertNotEmpty($dispatched); + self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); + } + + #[Test] + public function itActivatesEstablishmentAfterProvisioning(): void + { + $establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment(); + + $handler = $this->buildHandler(establishmentRepository: $establishmentRepo); + $handler($this->command()); + + $establishment = $establishmentRepo->get( + EstablishmentId::fromString(self::ESTABLISHMENT_ID), + ); + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + } + + #[Test] + public function itIsIdempotentWhenAdminAlreadyExists(): void + { + $userRepository = new InMemoryUserRepository(); + $dispatched = []; + $eventBus = $this->spyEventBus($dispatched); + + $handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus); + + // First call creates the admin + $handler($this->command()); + self::assertCount(1, $dispatched); + self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]); + + // Second call is idempotent — re-sends invitation + $dispatched = []; + $handler($this->command()); + self::assertCount(1, $dispatched); + self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]); + } + + #[Test] + public function itSwitchesDatabaseAndRestores(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $handler = $this->buildHandler(databaseSwitcher: $switcher); + $handler($this->command()); + + self::assertCount(1, $switcher->switchedTo); + self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]); + self::assertTrue($switcher->restoredToDefault); + } + + #[Test] + public function itPreservesQueryParametersInDatabaseUrl(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $handler = $this->buildHandler(databaseSwitcher: $switcher); + $handler($this->command()); + + self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]); + } + + #[Test] + public function itRestoresDatabaseEvenOnFailure(): void + { + $switcher = new SpyDatabaseSwitcher(); + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willThrowException(new RuntimeException('Event bus failure')); + + $handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus); + + try { + $handler($this->command()); + } catch (RuntimeException) { + // Expected + } + + self::assertTrue($switcher->restoredToDefault); + } + + private function command(): ProvisionEstablishmentCommand + { + return new ProvisionEstablishmentCommand( + establishmentId: self::ESTABLISHMENT_ID, + establishmentTenantId: self::TENANT_ID, + databaseName: 'classeo_tenant_abc123', + subdomain: 'ecole-gamma', + adminEmail: 'admin@ecole-gamma.fr', + establishmentName: 'École Gamma', + ); + } + + private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository + { + $repo = new InMemoryEstablishmentRepository(); + $establishment = Establishment::reconstitute( + id: EstablishmentId::fromString(self::ESTABLISHMENT_ID), + tenantId: TenantId::fromString(self::TENANT_ID), + name: 'École Gamma', + subdomain: 'ecole-gamma', + databaseName: 'classeo_tenant_abc123', + status: EstablishmentStatus::PROVISIONING, + createdAt: new DateTimeImmutable('2026-04-07 10:00:00'), + createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'), + ); + $repo->save($establishment); + + return $repo; + } + + /** + * @param object[] $dispatched + */ + private function spyEventBus(array &$dispatched): MessageBusInterface + { + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(static function (object $message) use (&$dispatched): Envelope { + $dispatched[] = $message; + + return new Envelope($message); + }); + + return $eventBus; + } + + private function buildHandler( + ?TenantProvisioner $provisioner = null, + ?InMemoryUserRepository $userRepository = null, + ?SpyDatabaseSwitcher $databaseSwitcher = null, + ?InMemoryEstablishmentRepository $establishmentRepository = null, + ?MessageBusInterface $eventBus = null, + ): ProvisionEstablishmentHandler { + $provisioner ??= $this->createMock(TenantProvisioner::class); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-04-07 10:00:00'); + } + }; + + $userRepository ??= new InMemoryUserRepository(); + + $databaseSwitcher ??= new SpyDatabaseSwitcher(); + + $establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment(); + + $eventBus ??= $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(static fn (object $m): Envelope => new Envelope($m)); + + return new ProvisionEstablishmentHandler( + tenantProvisioner: $provisioner, + inviteUserHandler: new InviteUserHandler($userRepository, $clock), + userRepository: $userRepository, + clock: $clock, + databaseSwitcher: $databaseSwitcher, + establishmentRepository: $establishmentRepository, + eventBus: $eventBus, + logger: new NullLogger(), + masterDatabaseUrl: self::MASTER_URL, + ); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php new file mode 100644 index 0000000..b6ae9e2 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/ProvisioningIntegrationTest.php @@ -0,0 +1,166 @@ +establishmentRepository = new InMemoryEstablishmentRepository(); + $createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(new SecuritySuperAdmin( + SuperAdminId::fromString(self::SUPER_ADMIN_ID), + 'superadmin@classeo.fr', + 'hashed', + )); + + $this->provisionCommand = null; + $commandBus = $this->createMock(MessageBusInterface::class); + $commandBus->method('dispatch') + ->willReturnCallback(function (object $message): Envelope { + if ($message instanceof ProvisionEstablishmentCommand) { + $this->provisionCommand = $message; + } + + return new Envelope($message); + }); + + $processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus); + + $input = new EstablishmentResource(); + $input->name = 'École Test'; + $input->subdomain = 'ecole-test'; + $input->adminEmail = 'admin@ecole-test.fr'; + + $processor->process($input, new Post()); + + // Phase 2: Provisioning handler processes the command + self::assertNotNull($this->provisionCommand); + + $this->userRepository = new InMemoryUserRepository(); + $this->dispatchedEvents = []; + + $eventBus = $this->createMock(MessageBusInterface::class); + $eventBus->method('dispatch') + ->willReturnCallback(function (object $message): Envelope { + $this->dispatchedEvents[] = $message; + + return new Envelope($message); + }); + + $provisioner = $this->createMock(TenantProvisioner::class); + + $switcher = new SpyDatabaseSwitcher(); + + $provisionHandler = new ProvisionEstablishmentHandler( + tenantProvisioner: $provisioner, + inviteUserHandler: new InviteUserHandler($this->userRepository, $clock), + userRepository: $this->userRepository, + clock: $clock, + databaseSwitcher: $switcher, + establishmentRepository: $this->establishmentRepository, + eventBus: $eventBus, + logger: new NullLogger(), + masterDatabaseUrl: self::MASTER_URL, + ); + + $provisionHandler($this->provisionCommand); + } + + #[Test] + public function processorCreatesEstablishmentInProvisioningStatus(): void + { + $this->runFullFlow(); + + $establishments = $this->establishmentRepository->findAll(); + self::assertCount(1, $establishments); + self::assertSame('École Test', $establishments[0]->name); + } + + #[Test] + public function processorDispatchesProvisioningCommandWithAdminEmail(): void + { + $this->runFullFlow(); + + self::assertNotNull($this->provisionCommand); + self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail); + self::assertSame('ecole-test', $this->provisionCommand->subdomain); + } + + #[Test] + public function provisioningCreatesAdminUserWithCorrectRole(): void + { + $this->runFullFlow(); + + $users = $this->userRepository->findAllByTenant( + TenantId::fromString($this->provisionCommand->establishmentTenantId), + ); + self::assertCount(1, $users); + self::assertSame('admin@ecole-test.fr', (string) $users[0]->email); + self::assertSame(Role::ADMIN, $users[0]->role); + } + + #[Test] + public function provisioningActivatesEstablishmentAndDispatchesEvent(): void + { + $this->runFullFlow(); + + $establishments = $this->establishmentRepository->findAll(); + self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status); + + self::assertCount(1, $this->dispatchedEvents); + self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php new file mode 100644 index 0000000..779a213 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Provisioning/SpyDatabaseSwitcher.php @@ -0,0 +1,32 @@ +switchedTo[] = $databaseUrl; + } + + public function useDefaultDatabase(): void + { + $this->restoredToDefault = true; + } + + public function currentDatabaseUrl(): ?string + { + return null; + } +} diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index 0eacf78..0faf9d9 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -1,6 +1,16 @@ -{$APP_DOMAIN} { +# Domaine principal et sous-domaines wildcard (multi-tenant) +# Caddy provisionne automatiquement les certificats TLS via Let's Encrypt. +# Le wildcard nécessite un DNS challenge : configurer CADDY_DNS_PROVIDER +# et les credentials DNS dans les variables d'environnement. + +{$APP_DOMAIN}, *.{$APP_DOMAIN} { encode zstd gzip + # Le certificat wildcard nécessite un DNS challenge + tls { + dns {$CADDY_DNS_PROVIDER:cloudflare} {$CADDY_DNS_API_TOKEN} + } + handle /api/* { reverse_proxy php:8000 } diff --git a/frontend/e2e/role-assignment.spec.ts b/frontend/e2e/role-assignment.spec.ts new file mode 100644 index 0000000..cc0b2c3 --- /dev/null +++ b/frontend/e2e/role-assignment.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-roles-admin@example.com'; +const ADMIN_PASSWORD = 'RolesAdmin123'; +const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`; +const TARGET_PASSWORD = 'RolesTarget123'; + +test.describe('Multi-Role Assignment (FR5) [P2]', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create target user with single role (PROF) + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 60000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function openRolesModalForTarget(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/admin/users`); + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + + // Search for the target user (paginated list may not show them on page 1) + await page.getByRole('searchbox').fill(TARGET_EMAIL); + await page.waitForTimeout(500); // debounce + + // Find the target user row and click "Rôles" button + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); + await expect(targetRow).toBeVisible({ timeout: 10000 }); + await targetRow.getByRole('button', { name: 'Rôles' }).click(); + + // Modal should appear + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles'); + } + + test('[P2] admin can open role modal showing current roles', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Target user email should be displayed in modal + await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL); + + // ROLE_PROF should be checked (current role) + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + + // Other roles should be unchecked + const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]'); + await expect(adminCheckbox).not.toBeChecked(); + }); + + test('[P2] admin can assign multiple roles to a user', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Add Vie Scolaire role in addition to PROF + const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }); + await vieScolaireLabel.locator('input[type="checkbox"]').check(); + + // Both should now be checked + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked(); + + // Save + const saveResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + const saveResponse = await saveResponsePromise; + expect(saveResponse.status()).toBeLessThan(400); + + // Success message should appear + await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 }); + }); + + test('[P2] assigned roles persist after page reload', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Both PROF and VIE_SCOLAIRE should still be checked after reload + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]'); + + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireCheckbox).toBeChecked(); + }); + + test('[P2] admin can remove a role while keeping at least one', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Uncheck Vie Scolaire (added in previous test) + const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]'); + await vieScolaireCheckbox.uncheck(); + + // PROF should still be checked + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + await expect(vieScolaireCheckbox).not.toBeChecked(); + + // Save + const saveResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT' + ); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + await saveResponsePromise; + + await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 }); + }); + + test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + // Only PROF should be checked now (after previous test removed VIE_SCOLAIRE) + const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]'); + await expect(profCheckbox).toBeChecked(); + + // Last role checkbox should be disabled + await expect(profCheckbox).toBeDisabled(); + + // "(dernier rôle)" hint should be visible + await expect( + page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint') + ).toContainText('dernier rôle'); + }); + + test('[P2] role modal can be closed with Escape', async ({ page }) => { + await loginAsAdmin(page); + await openRolesModalForTarget(page); + + await page.getByRole('dialog').press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/super-admin-provisioning.spec.ts b/frontend/e2e/super-admin-provisioning.spec.ts new file mode 100644 index 0000000..dd0b52a --- /dev/null +++ b/frontend/e2e/super-admin-provisioning.spec.ts @@ -0,0 +1,205 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const SA_PASSWORD = 'SuperAdmin123'; +const UNIQUE_SUFFIX = Date.now(); + +function getSuperAdminEmail(browserName: string): string { + return `e2e-prov-sa-${browserName}@test.com`; +} + +// eslint-disable-next-line no-empty-pattern +test.beforeAll(async ({}, testInfo) => { + const browserName = testInfo.project.name; + const saEmail = getSuperAdminEmail(browserName); + + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + } catch (error) { + console.error(`[${browserName}] Failed to create super admin:`, error); + } +}); + +async function loginAsSuperAdmin( + page: import('@playwright/test').Page, + email: string +) { + await page.goto(`${ALPHA_URL}/login`); + await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); + + await page.locator('#email').fill(email); + await page.locator('#password').fill(SA_PASSWORD); + + const submitButton = page.getByRole('button', { name: /se connecter/i }); + await Promise.all([ + page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }), + submitButton.click() + ]); +} + +async function navigateToEstablishments(page: import('@playwright/test').Page) { + const link = page.getByRole('link', { name: /établissements/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + link.click() + ]); + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 }); +} + +async function navigateToCreateForm(page: import('@playwright/test').Page) { + const newLink = page.getByRole('link', { name: /nouvel établissement/i }); + await expect(newLink).toBeVisible({ timeout: 10000 }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }), + newLink.click() + ]); + await expect(page.locator('#name')).toBeVisible({ timeout: 10000 }); +} + +test.describe('Establishment Provisioning (Story 2-17) [P1]', () => { + test.describe.configure({ mode: 'serial' }); + + test.describe('Subdomain Auto-generation', () => { + test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await page.locator('#name').fill('École Saint-Exupéry'); + + // Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase + await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery'); + }); + + test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr'); + }); + }); + + test.describe('Create Establishment Flow', () => { + const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`; + const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`; + + test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + // Fill in the form + await page.locator('#name').fill(establishmentName); + await page.locator('#adminEmail').fill(adminEmailForEstab); + + // Subdomain should be auto-generated + const subdomain = await page.locator('#subdomain').inputValue(); + expect(subdomain.length).toBeGreaterThan(0); + + // Submit + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeEnabled(); + + const apiResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST' + ); + + await submitButton.click(); + + // Verify API returns establishment in provisioning status + const apiResponse = await apiResponsePromise; + expect(apiResponse.status()).toBeLessThan(400); + const body = await apiResponse.json(); + expect(body.status).toBe('provisioning'); + + // Should redirect back to establishments list + await page.waitForURL('**/super-admin/establishments', { timeout: 15000 }); + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] created establishment appears in the list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + + // The establishment created in previous test should be visible + await expect(page.locator('table')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 }); + }); + + test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + + // Find the row for our establishment + const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) }); + await expect(row).toBeVisible({ timeout: 10000 }); + + // Status badge should be visible (provisioning status already verified via API response in creation test) + const badge = row.locator('.badge'); + await expect(badge).toBeVisible(); + await expect(badge).not.toHaveText(''); + }); + }); + + test.describe('Form Validation', () => { + test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeDisabled(); + }); + + test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + await page.locator('#name').fill('Test School'); + await page.locator('#adminEmail').fill('admin@test.com'); + + const submitButton = page.getByRole('button', { name: /créer l'établissement/i }); + await expect(submitButton).toBeEnabled(); + }); + + test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + await loginAsSuperAdmin(page, email); + await navigateToEstablishments(page); + await navigateToCreateForm(page); + + const cancelLink = page.getByRole('link', { name: /annuler/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + cancelLink.click() + ]); + + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible(); + }); + }); +}); diff --git a/frontend/src/routes/super-admin/establishments/+page.svelte b/frontend/src/routes/super-admin/establishments/+page.svelte index 5e57483..4eaf9f0 100644 --- a/frontend/src/routes/super-admin/establishments/+page.svelte +++ b/frontend/src/routes/super-admin/establishments/+page.svelte @@ -71,8 +71,16 @@ {establishment.subdomain} - - {establishment.status === 'active' ? 'Actif' : 'Inactif'} + + {establishment.status === 'active' + ? 'Actif' + : establishment.status === 'provisioning' + ? 'Provisioning…' + : 'Inactif'} @@ -207,6 +215,11 @@ color: #16a34a; } + .badge.provisioning { + background: #fef3c7; + color: #d97706; + } + .actions-cell { white-space: nowrap; }