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.
242 lines
8.4 KiB
PHP
242 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Administration\Infrastructure\Console;
|
|
|
|
use App\Administration\Application\Port\PasswordHasher;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\StatutCompte;
|
|
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;
|
|
use Symfony\Component\Console\Command\Command;
|
|
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.
|
|
*
|
|
* Unlike the activation token command, this creates a user that can
|
|
* immediately log in with the provided password.
|
|
*/
|
|
#[AsCommand(
|
|
name: 'app:dev:create-test-user',
|
|
description: 'Creates an already-activated test user for E2E login tests',
|
|
)]
|
|
final class CreateTestUserCommand extends Command
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepository $userRepository,
|
|
private readonly PasswordHasher $passwordHasher,
|
|
private readonly TenantRegistry $tenantRegistry,
|
|
private readonly Clock $clock,
|
|
#[Autowire('%kernel.project_dir%')]
|
|
private readonly string $projectDir,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this
|
|
->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('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
|
|
{
|
|
$io = new SymfonyStyle($input, $output);
|
|
|
|
/** @var string $email */
|
|
$email = $input->getOption('email');
|
|
/** @var string $password */
|
|
$password = $input->getOption('password');
|
|
/** @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;
|
|
|
|
$role = Role::tryFrom($roleName);
|
|
if ($role === null) {
|
|
$validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases());
|
|
$io->error(sprintf(
|
|
'Invalid role "%s". Valid roles: %s',
|
|
$roleInput,
|
|
implode(', ', $validRoles)
|
|
));
|
|
|
|
return Command::FAILURE;
|
|
}
|
|
|
|
// Resolve tenant from subdomain
|
|
try {
|
|
$tenantConfig = $this->tenantRegistry->getBySubdomain($tenantSubdomain);
|
|
$tenantId = $tenantConfig->tenantId;
|
|
} catch (TenantNotFoundException) {
|
|
$availableTenants = array_map(
|
|
static fn ($config) => $config->subdomain,
|
|
$this->tenantRegistry->getAllConfigs()
|
|
);
|
|
$io->error(sprintf(
|
|
'Tenant "%s" not found. Available tenants: %s',
|
|
$tenantSubdomain,
|
|
implode(', ', $availableTenants)
|
|
));
|
|
|
|
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
|
|
$existingUser = $this->userRepository->findByEmail(new Email($email), $tenantId);
|
|
if ($existingUser !== null) {
|
|
$io->warning(sprintf('User with email "%s" already exists. Returning existing user.', $email));
|
|
|
|
$io->table(
|
|
['Property', 'Value'],
|
|
[
|
|
['User ID', (string) $existingUser->id],
|
|
['Email', $email],
|
|
['Password', $password],
|
|
['Role', $existingUser->role->value],
|
|
['Status', $existingUser->statut->value],
|
|
]
|
|
);
|
|
|
|
return Command::SUCCESS;
|
|
}
|
|
|
|
// Create activated user using reconstitute to bypass domain validation
|
|
$hashedPassword = $this->passwordHasher->hash($password);
|
|
|
|
$user = User::reconstitute(
|
|
id: UserId::generate(),
|
|
email: new Email($email),
|
|
roles: [$role],
|
|
tenantId: $tenantId,
|
|
schoolName: $schoolName,
|
|
statut: StatutCompte::ACTIF,
|
|
dateNaissance: null,
|
|
createdAt: $now,
|
|
hashedPassword: $hashedPassword,
|
|
activatedAt: $now,
|
|
consentementParental: null,
|
|
firstName: $firstName,
|
|
lastName: $lastName,
|
|
);
|
|
|
|
$this->userRepository->save($user);
|
|
|
|
$io->success('Test user created successfully!');
|
|
|
|
$io->table(
|
|
['Property', 'Value'],
|
|
[
|
|
['User ID', (string) $user->id],
|
|
['Email', $email],
|
|
['Password', $password],
|
|
['Role', $role->value],
|
|
['Tenant', $tenantSubdomain],
|
|
['School', $schoolName],
|
|
['Status', StatutCompte::ACTIF->value],
|
|
]
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|