From c3ec2f9f6f14db7e9d4ba84c79808b3b7d563c62 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Jun 2023 10:26:28 +0200 Subject: [PATCH 1/8] Display warning next to SQLite when selecting DB --- src/Config/Option/Database/DatabaseDriverConfigOption.php | 2 +- .../Option/Database/DatabaseDriverConfigOptionTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Config/Option/Database/DatabaseDriverConfigOption.php b/src/Config/Option/Database/DatabaseDriverConfigOption.php index 42f7811..91a6608 100644 --- a/src/Config/Option/Database/DatabaseDriverConfigOption.php +++ b/src/Config/Option/Database/DatabaseDriverConfigOption.php @@ -18,7 +18,7 @@ class DatabaseDriverConfigOption extends BaseConfigOption 'MariaDB' => DatabaseDriver::MYSQL, 'PostgreSQL' => DatabaseDriver::POSTGRES, 'MicrosoftSQL' => DatabaseDriver::MSSQL, - 'SQLite' => DatabaseDriver::SQLITE, + 'SQLite [Not supported for production]' => DatabaseDriver::SQLITE, ]; public function getEnvVar(): string diff --git a/test/Config/Option/Database/DatabaseDriverConfigOptionTest.php b/test/Config/Option/Database/DatabaseDriverConfigOptionTest.php index 050794f..3147b65 100644 --- a/test/Config/Option/Database/DatabaseDriverConfigOptionTest.php +++ b/test/Config/Option/Database/DatabaseDriverConfigOptionTest.php @@ -28,7 +28,7 @@ public function returnsExpectedEnvVar(): void #[Test] public function expectedQuestionIsAsked(): void { - $expectedAnswer = DatabaseDriver::SQLITE->value; + $expectedAnswer = DatabaseDriver::POSTGRES->value; $io = $this->createMock(StyleInterface::class); $io->expects($this->once())->method('choice')->with( 'Select database type', @@ -37,10 +37,10 @@ public function expectedQuestionIsAsked(): void 'MariaDB', 'PostgreSQL', 'MicrosoftSQL', - 'SQLite', + 'SQLite [Not supported for production]', ], 'MySQL', - )->willReturn('SQLite'); + )->willReturn('PostgreSQL'); $answer = $this->configOption->ask($io, []); From 70cb1ac0182b42e20a05d54076086df3bffbba98 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Jun 2023 10:27:21 +0200 Subject: [PATCH 2/8] Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a878492..f1fb266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* Display warning next to SQLite when selecting database, informing it is not supported for production setups. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [8.4.2] - 2023-06-15 ### Added * *Nothing* From 20700ae358da932a74b2811f333ce6afbf1f3f10 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jul 2023 09:39:58 +0200 Subject: [PATCH 3/8] Display different verbosity hint if init command is run non-interactive --- src/Command/InitCommand.php | 9 ++-- src/Service/InstallationCommandsRunner.php | 9 ++-- .../InstallationCommandsRunnerInterface.php | 2 +- test/Command/InitCommandTest.php | 19 +++++--- .../InstallationCommandsRunnerTest.php | 47 ++++++++++++++----- 5 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/Command/InitCommand.php b/src/Command/InitCommand.php index 4d68cd2..6384e1c 100644 --- a/src/Command/InitCommand.php +++ b/src/Command/InitCommand.php @@ -59,9 +59,10 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int $commands = InstallationCommand::resolveCommandsForConfig($config); $io = new SymfonyStyle($input, $output); - return every( - $commands, - fn (InstallationCommand $command) => $this->commandsRunner->execPhpCommand($command->value, $io), - ) ? 0 : -1; + return every($commands, fn (InstallationCommand $command) => $this->commandsRunner->execPhpCommand( + $command->value, + $io, + $input->isInteractive(), + )) ? 0 : -1; } } diff --git a/src/Service/InstallationCommandsRunner.php b/src/Service/InstallationCommandsRunner.php index 7c91e68..a93a4ee 100644 --- a/src/Service/InstallationCommandsRunner.php +++ b/src/Service/InstallationCommandsRunner.php @@ -28,7 +28,7 @@ public function __construct( $this->phpBinary = $phpFinder->find(false) ?: 'php'; } - public function execPhpCommand(string $name, SymfonyStyle $io): bool + public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive): bool { $commandConfig = $this->commandsMapping[$name] ?? null; if ($commandConfig === null) { @@ -61,14 +61,17 @@ public function execPhpCommand(string $name, SymfonyStyle $io): bool $isSuccess = $process->isSuccessful(); $isWarning = ! $isSuccess && ! $failOnError; $isVerbose = $io->isVerbose(); + $verbosityIndicator = $interactive ? 'Run with -vvv' : 'Set SHELL_VERBOSITY=3'; if ($isSuccess) { $io->writeln(' Success!'); } elseif ($isWarning) { $io->write(' Warning!'); - $io->writeln($isVerbose ? '' : ' Run with -vvv to see error.'); + $io->writeln($isVerbose ? '' : sprintf(' %s to see error.', $verbosityIndicator)); } elseif (! $isVerbose) { - $io->error(sprintf('%s. Run this command with -vvv to see specific error info.', $errorMessage)); + $io->error( + sprintf('%s. %s to see specific error info.', $errorMessage, $verbosityIndicator), + ); } if ($printOutput) { diff --git a/src/Service/InstallationCommandsRunnerInterface.php b/src/Service/InstallationCommandsRunnerInterface.php index 3b96597..c55eb7b 100644 --- a/src/Service/InstallationCommandsRunnerInterface.php +++ b/src/Service/InstallationCommandsRunnerInterface.php @@ -8,5 +8,5 @@ interface InstallationCommandsRunnerInterface { - public function execPhpCommand(string $name, SymfonyStyle $io): bool; + public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive): bool; } diff --git a/test/Command/InitCommandTest.php b/test/Command/InitCommandTest.php index e5fa9d0..8db1967 100644 --- a/test/Command/InitCommandTest.php +++ b/test/Command/InitCommandTest.php @@ -34,14 +34,15 @@ public function setUp(): void } #[Test, DataProvider('provideInputs')] - public function expectedCommandsAreRunBasedOnInput(array $input, array $expectedCommands): void + public function expectedCommandsAreRunBasedOnInput(array $input, array $expectedCommands, bool $interactive): void { $this->commandsRunner->expects($this->exactly(count($expectedCommands)))->method('execPhpCommand')->with( $this->callback(fn (string $commandName) => contains($expectedCommands, $commandName)), $this->anything(), + $interactive, )->willReturn(true); - $this->tester->execute($input); + $this->tester->execute($input, ['interactive' => $interactive]); } public static function provideInputs(): iterable @@ -51,11 +52,17 @@ public static function provideInputs(): iterable InstallationCommand::DB_MIGRATE->value, InstallationCommand::ORM_PROXIES->value, InstallationCommand::GEOLITE_DOWNLOAD_DB->value, - ]]; + ], true]; + yield 'non-interactive' => [[], [ + InstallationCommand::DB_CREATE_SCHEMA->value, + InstallationCommand::DB_MIGRATE->value, + InstallationCommand::ORM_PROXIES->value, + InstallationCommand::GEOLITE_DOWNLOAD_DB->value, + ], false]; yield 'skips' => [['--skip-initialize-db' => true, '--skip-download-geolite' => true], [ InstallationCommand::DB_MIGRATE->value, InstallationCommand::ORM_PROXIES->value, - ]]; + ], true]; yield 'all' => [[ '--clear-db-cache' => true, '--initial-api-key' => true, @@ -68,7 +75,7 @@ public static function provideInputs(): iterable InstallationCommand::GEOLITE_DOWNLOAD_DB->value, InstallationCommand::API_KEY_GENERATE->value, InstallationCommand::ROAD_RUNNER_BINARY_DOWNLOAD->value, - ]]; + ], true]; yield 'mixed' => [[ '--initial-api-key' => true, '--skip-download-geolite' => true, @@ -77,7 +84,7 @@ public static function provideInputs(): iterable InstallationCommand::DB_MIGRATE->value, InstallationCommand::ORM_PROXIES->value, InstallationCommand::API_KEY_GENERATE->value, - ]]; + ], true]; } #[Test, DataProvider('provideExitCodes')] diff --git a/test/Service/InstallationCommandsRunnerTest.php b/test/Service/InstallationCommandsRunnerTest.php index 356a932..ee53afa 100644 --- a/test/Service/InstallationCommandsRunnerTest.php +++ b/test/Service/InstallationCommandsRunnerTest.php @@ -58,7 +58,7 @@ private function buildCommands(): array #[Test] public function doesNothingWhenRequestedCommandDoesNotExist(): void { - self::assertFalse($this->commandsRunner->execPhpCommand('invalid', $this->io)); + self::assertFalse($this->commandsRunner->execPhpCommand('invalid', $this->io, interactive: true)); } #[Test, DataProvider('provideTimeouts')] @@ -85,7 +85,7 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { $this->io->expects($this->once())->method('writeln')->with(' Success!', $this->anything()); $this->io->expects($this->never())->method('error'); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io)); + self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true)); } public static function provideTimeouts(): iterable @@ -95,8 +95,11 @@ public static function provideTimeouts(): iterable } #[Test, DataProvider('provideExtraLines')] - public function returnsWarningWhenProcessFailsButErrorIsAllowed(bool $isVerbose, string $extraLine): void - { + public function returnsWarningWhenProcessFailsButErrorIsAllowed( + bool $isVerbose, + bool $isInteractive, + string $extraLine, + ): void { $name = 'bar'; $command = ['php', $name, 'something']; @@ -121,18 +124,23 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { $this->io->expects($this->once())->method('writeln')->with($extraLine); $this->io->expects($this->never())->method('error'); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io)); + self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive)); } public static function provideExtraLines(): iterable { - yield 'verbose output' => [true, '']; - yield 'not verbose output' => [false, ' Run with -vvv to see error.']; + yield 'interactive, verbose output' => [true, true, '']; + yield 'interactive, not verbose output' => [false, true, ' Run with -vvv to see error.']; + yield 'non-interactive, verbose output' => [true, false, '']; + yield 'non-interactive, not verbose output' => [false, false, ' Set SHELL_VERBOSITY=3 to see error.']; } - #[Test] - public function returnsErrorWhenProcessIsNotProperlyRun(): void - { + #[Test, DataProvider('provideInteractivityFlag')] + public function returnsErrorWhenProcessIsNotProperlyRun( + bool $isInteractive, + string $expectedError, + string $notExpectedError, + ): void { $name = 'foo'; $command = ['php', $name, 'something']; @@ -152,10 +160,23 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { }; }, ); - $this->io->expects($this->once())->method('error')->with($this->stringContains(sprintf('%s_error', $name))); + $this->io->expects($this->once())->method('error')->with($this->logicalAnd( + $this->stringContains(sprintf('%s_error', $name)), + $this->stringContains($expectedError), + $this->logicalNot($this->stringContains($notExpectedError)), + )); $this->io->expects($this->never())->method('writeln'); - self::assertFalse($this->commandsRunner->execPhpCommand($name, $this->io)); + self::assertFalse($this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive)); + } + + public static function provideInteractivityFlag(): iterable + { + $verbosityFlagMessage = 'Run with -vvv'; + $envVarMessage = 'Set SHELL_VERBOSITY=3'; + + yield 'interactive' => [true, $verbosityFlagMessage, $envVarMessage]; + yield 'non-interactive' => [false, $envVarMessage, $verbosityFlagMessage]; } #[Test] @@ -168,7 +189,7 @@ public function skipsNullCommands(): void $this->io->expects($this->never())->method('error'); $this->io->expects($this->once())->method('writeln')->with(' Skipped', $this->anything()); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io)); + self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true)); } private function createProcessMock(bool $isSuccessful): MockObject & Process From 1d3362c00dda4553ce66da80f67877ee2d23ebf2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jul 2023 09:46:40 +0200 Subject: [PATCH 4/8] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fb266..128e03f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed +* [#193](https://github.com/shlinkio/shlink-installer/issues/193) Display improved verbosity hint for installation commands based on `interactive` flag, suggesting `-vvv` for interactive executions, and `SHELL_VERBOSITY=3` for non-interactive ones. * Display warning next to SQLite when selecting database, informing it is not supported for production setups. ### Deprecated From e973e919dd3b9562aa75ccd3af640185200bdb46 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 1 Aug 2023 23:45:08 +0200 Subject: [PATCH 5/8] Use docker compose instead of docker-compose --- indocker | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indocker b/indocker index 362ccb4..d87e21c 100755 --- a/indocker +++ b/indocker @@ -1,3 +1,3 @@ #!/usr/bin/env bash -docker-compose run --rm shlink_installer_php /bin/sh -c "$*" +docker compose run --rm shlink_installer_php /bin/sh -c "$*" From 0d903cb6b63dc78c5dcce6f3b85fe0d481bacfc5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Sep 2023 08:44:48 +0200 Subject: [PATCH 6/8] Allow init --initial-api-key to receive a value for the initial key --- CHANGELOG.md | 4 +- config/config.php | 7 ++++ src/Command/InitCommand.php | 38 ++++++++++------- src/Command/Model/InitOption.php | 42 ++++++++++++++----- src/Model/CLIOption.php | 28 +++++++++++++ src/Model/FlagOption.php | 22 ---------- src/Model/ShlinkInitConfig.php | 8 +++- src/Service/InstallationCommandsRunner.php | 4 +- .../InstallationCommandsRunnerInterface.php | 2 +- src/Util/InstallationCommand.php | 23 ++++++---- .../InstallationCommandsRunnerTest.php | 19 ++++++--- 11 files changed, 129 insertions(+), 68 deletions(-) create mode 100644 src/Model/CLIOption.php delete mode 100644 src/Model/FlagOption.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 128e03f..adfb60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [8.5.0] - 2023-09-22 ### Added -* *Nothing* +* Improve `init` command's `--initial-api-key` flag, so that it can receive an optional value which will be used as the initial API key. ### Changed * [#193](https://github.com/shlinkio/shlink-installer/issues/193) Display improved verbosity hint for installation commands based on `interactive` flag, suggesting `-vvv` for interactive executions, and `SHELL_VERBOSITY=3` for non-interactive ones. diff --git a/config/config.php b/config/config.php index 29d7c12..5016bf7 100644 --- a/config/config.php +++ b/config/config.php @@ -274,6 +274,13 @@ 'failOnError' => false, 'printOutput' => true, ], + InstallationCommand::API_KEY_CREATE->value => [ + 'command' => null, // Disabled by default, to avoid dependency on consumer (Shlink) + 'initMessage' => 'Creating first API key...', + 'errorMessage' => 'Error creating first API key.', + 'failOnError' => false, + 'printOutput' => true, + ], InstallationCommand::ROAD_RUNNER_BINARY_DOWNLOAD->value => [ 'command' => 'vendor/bin/rr get --no-interaction --no-config --location bin/', 'initMessage' => 'Downloading RoadRunner binary...', diff --git a/src/Command/InitCommand.php b/src/Command/InitCommand.php index 6384e1c..8bf3d0b 100644 --- a/src/Command/InitCommand.php +++ b/src/Command/InitCommand.php @@ -5,7 +5,7 @@ namespace Shlinkio\Shlink\Installer\Command; use Shlinkio\Shlink\Installer\Command\Model\InitOption; -use Shlinkio\Shlink\Installer\Model\FlagOption; +use Shlinkio\Shlink\Installer\Model\CLIOption; use Shlinkio\Shlink\Installer\Model\ShlinkInitConfig; use Shlinkio\Shlink\Installer\Service\InstallationCommandsRunnerInterface; use Shlinkio\Shlink\Installer\Util\InstallationCommand; @@ -20,21 +20,21 @@ class InitCommand extends Command { public const NAME = 'init'; - private readonly FlagOption $skipInitDb; - private readonly FlagOption $clearDbCache; - private readonly FlagOption $initialApiKey; - private readonly FlagOption $downloadRoadRunnerBin; - private readonly FlagOption $skipDownloadGeoLiteDb; + private readonly CLIOption $skipInitDb; + private readonly CLIOption $clearDbCache; + private readonly CLIOption $initialApiKey; + private readonly CLIOption $downloadRoadRunnerBin; + private readonly CLIOption $skipDownloadGeoLiteDb; public function __construct(private readonly InstallationCommandsRunnerInterface $commandsRunner) { parent::__construct(); - $this->skipInitDb = InitOption::SKIP_INITIALIZE_DB->toFlagOption($this); - $this->clearDbCache = InitOption::CLEAR_DB_CACHE->toFlagOption($this); - $this->initialApiKey = InitOption::INITIAL_API_KEY->toFlagOption($this); - $this->downloadRoadRunnerBin = InitOption::DOWNLOAD_RR_BINARY->toFlagOption($this); - $this->skipDownloadGeoLiteDb = InitOption::SKIP_DOWNLOAD_GEOLITE->toFlagOption($this); + $this->initialApiKey = InitOption::INITIAL_API_KEY->toCLIOption($this); + $this->skipInitDb = InitOption::SKIP_INITIALIZE_DB->toCLIOption($this); + $this->clearDbCache = InitOption::CLEAR_DB_CACHE->toCLIOption($this); + $this->downloadRoadRunnerBin = InitOption::DOWNLOAD_RR_BINARY->toCLIOption($this); + $this->skipDownloadGeoLiteDb = InitOption::SKIP_DOWNLOAD_GEOLITE->toCLIOption($this); } protected function configure(): void @@ -59,10 +59,16 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int $commands = InstallationCommand::resolveCommandsForConfig($config); $io = new SymfonyStyle($input, $output); - return every($commands, fn (InstallationCommand $command) => $this->commandsRunner->execPhpCommand( - $command->value, - $io, - $input->isInteractive(), - )) ? 0 : -1; + return every($commands, function (array $commandInfo) use ($input, $io): bool { + /** @var array{InstallationCommand, string | null} $commandInfo */ + [$command, $arg] = $commandInfo; + + return $this->commandsRunner->execPhpCommand( + name: $command->value, + io: $io, + interactive: $input->isInteractive(), + args: $arg !== null ? [$arg] : [], + ); + }) ? 0 : -1; } } diff --git a/src/Command/Model/InitOption.php b/src/Command/Model/InitOption.php index a18a820..492e53b 100644 --- a/src/Command/Model/InitOption.php +++ b/src/Command/Model/InitOption.php @@ -2,8 +2,9 @@ namespace Shlinkio\Shlink\Installer\Command\Model; -use Shlinkio\Shlink\Installer\Model\FlagOption; +use Shlinkio\Shlink\Installer\Model\CLIOption; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputOption; enum InitOption: string { @@ -18,20 +19,41 @@ public function asCliFlag(): string return '--' . $this->value; } - public function toFlagOption(Command $command): FlagOption + public function description(): string { - $description = match ($this) { - self:: SKIP_INITIALIZE_DB => + return match ($this) { + self::SKIP_INITIALIZE_DB => 'Skip the initial empty database creation. It will make this command fail on a later stage if the ' . 'database was not created manually.', - self:: CLEAR_DB_CACHE => 'Clear the database metadata cache.', - self:: INITIAL_API_KEY => 'Create and print initial admin API key.', - self:: DOWNLOAD_RR_BINARY => - 'Download a RoadRunner binary. Useful only if you plan to serve Shlink with Roadrunner.', - self:: SKIP_DOWNLOAD_GEOLITE => + self::CLEAR_DB_CACHE => 'Clear the database metadata cache.', + self::INITIAL_API_KEY => + 'Create an initial admin API key. A random one will be generated and printed if no value is provided.', + self::DOWNLOAD_RR_BINARY => + 'Download a RoadRunner binary. Useful only if you plan to serve Shlink with Roadrunner.', + self::SKIP_DOWNLOAD_GEOLITE => 'Skip downloading the initial GeoLite DB file. Shlink will try to download it the first time it needs ' . 'to geolocate visits.', }; - return new FlagOption($command, $this->value, $description); + } + + public function valueType(): int + { + return match ($this) { + self::INITIAL_API_KEY => InputOption::VALUE_OPTIONAL, + default => InputOption::VALUE_NONE, + }; + } + + public function defaultValue(): bool|null + { + return match ($this) { + self::INITIAL_API_KEY => false, + default => null, + }; + } + + public function toCLIOption(Command $command): CLIOption + { + return new CLIOption($command, $this); } } diff --git a/src/Model/CLIOption.php b/src/Model/CLIOption.php new file mode 100644 index 0000000..0b8876d --- /dev/null +++ b/src/Model/CLIOption.php @@ -0,0 +1,28 @@ +addOption( + $initOption->value, + null, + $initOption->valueType(), + $initOption->description(), + $this->initOption->defaultValue(), + ); + } + + public function get(InputInterface $input): mixed + { + return $input->getOption($this->initOption->value); + } +} diff --git a/src/Model/FlagOption.php b/src/Model/FlagOption.php deleted file mode 100644 index df93c02..0000000 --- a/src/Model/FlagOption.php +++ /dev/null @@ -1,22 +0,0 @@ -addOption($name, null, InputOption::VALUE_NONE, $description); - } - - public function get(InputInterface $input): bool - { - return $input->getOption($this->name); - } -} diff --git a/src/Model/ShlinkInitConfig.php b/src/Model/ShlinkInitConfig.php index 571cbf4..9ead48d 100644 --- a/src/Model/ShlinkInitConfig.php +++ b/src/Model/ShlinkInitConfig.php @@ -10,7 +10,13 @@ public function __construct( public readonly bool $initializeDb, public readonly bool $clearDbCache, public readonly bool $downloadRoadrunnerBinary, - public readonly bool $generateApiKey, + /** + * False: Do not generate an initial API key. + * String: Use provided value as the initial API key. + * Null: Auto-generate a random initial API key. + * @todo Change to string|false|null once PHP 8.1 is no longer supported + */ + public readonly string|bool|null $generateApiKey, public readonly bool $downloadGeoLiteDb, ) { } diff --git a/src/Service/InstallationCommandsRunner.php b/src/Service/InstallationCommandsRunner.php index a93a4ee..5a45f32 100644 --- a/src/Service/InstallationCommandsRunner.php +++ b/src/Service/InstallationCommandsRunner.php @@ -28,7 +28,7 @@ public function __construct( $this->phpBinary = $phpFinder->find(false) ?: 'php'; } - public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive): bool + public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive, array $args): bool { $commandConfig = $this->commandsMapping[$name] ?? null; if ($commandConfig === null) { @@ -50,7 +50,7 @@ public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive return true; } - $command = [$this->phpBinary, ...$this->commandToArray($command)]; + $command = [$this->phpBinary, ...$this->commandToArray($command), ...$args]; $io->write( sprintf(' [Running "%s"] ', implode(' ', $command)), false, diff --git a/src/Service/InstallationCommandsRunnerInterface.php b/src/Service/InstallationCommandsRunnerInterface.php index c55eb7b..cf57529 100644 --- a/src/Service/InstallationCommandsRunnerInterface.php +++ b/src/Service/InstallationCommandsRunnerInterface.php @@ -8,5 +8,5 @@ interface InstallationCommandsRunnerInterface { - public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive): bool; + public function execPhpCommand(string $name, SymfonyStyle $io, bool $interactive, array $args): bool; } diff --git a/src/Util/InstallationCommand.php b/src/Util/InstallationCommand.php index bc17712..53b539b 100644 --- a/src/Util/InstallationCommand.php +++ b/src/Util/InstallationCommand.php @@ -6,6 +6,8 @@ use Shlinkio\Shlink\Installer\Model\ShlinkInitConfig; +use function is_string; + enum InstallationCommand: string { case DB_CREATE_SCHEMA = 'db_create_schema'; @@ -14,34 +16,37 @@ enum InstallationCommand: string case ORM_CLEAR_CACHE = 'orm_clear_cache'; case GEOLITE_DOWNLOAD_DB = 'geolite_download_db'; case API_KEY_GENERATE = 'api_key_generate'; + case API_KEY_CREATE = 'api_key_create'; case ROAD_RUNNER_BINARY_DOWNLOAD = 'road_runner_update'; /** - * @return iterable + * @return iterable */ public static function resolveCommandsForConfig(ShlinkInitConfig $config): iterable { if ($config->initializeDb) { - yield self::DB_CREATE_SCHEMA; + yield [self::DB_CREATE_SCHEMA, null]; } - yield self::DB_MIGRATE; - yield self::ORM_PROXIES; + yield [self::DB_MIGRATE, null]; + yield [self::ORM_PROXIES, null]; if ($config->clearDbCache) { - yield self::ORM_CLEAR_CACHE; + yield [self::ORM_CLEAR_CACHE, null]; } if ($config->downloadGeoLiteDb) { - yield self::GEOLITE_DOWNLOAD_DB; + yield [self::GEOLITE_DOWNLOAD_DB, null]; } - if ($config->generateApiKey) { - yield self::API_KEY_GENERATE; + if ($config->generateApiKey === null) { + yield [self::API_KEY_GENERATE, null]; + } elseif (is_string($config->generateApiKey)) { + yield [self::API_KEY_CREATE, $config->generateApiKey]; } if ($config->downloadRoadrunnerBinary) { - yield self::ROAD_RUNNER_BINARY_DOWNLOAD; + yield [self::ROAD_RUNNER_BINARY_DOWNLOAD, null]; } } } diff --git a/test/Service/InstallationCommandsRunnerTest.php b/test/Service/InstallationCommandsRunnerTest.php index ee53afa..be26b10 100644 --- a/test/Service/InstallationCommandsRunnerTest.php +++ b/test/Service/InstallationCommandsRunnerTest.php @@ -58,7 +58,7 @@ private function buildCommands(): array #[Test] public function doesNothingWhenRequestedCommandDoesNotExist(): void { - self::assertFalse($this->commandsRunner->execPhpCommand('invalid', $this->io, interactive: true)); + self::assertFalse($this->commandsRunner->execPhpCommand('invalid', $this->io, interactive: true, args: [])); } #[Test, DataProvider('provideTimeouts')] @@ -85,7 +85,7 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { $this->io->expects($this->once())->method('writeln')->with(' Success!', $this->anything()); $this->io->expects($this->never())->method('error'); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true)); + self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true, args: [])); } public static function provideTimeouts(): iterable @@ -124,7 +124,9 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { $this->io->expects($this->once())->method('writeln')->with($extraLine); $this->io->expects($this->never())->method('error'); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive)); + self::assertTrue( + $this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive, args: []), + ); } public static function provideExtraLines(): iterable @@ -167,7 +169,9 @@ function (string $message) use ($writeCallMatcher, $name, $command): void { )); $this->io->expects($this->never())->method('writeln'); - self::assertFalse($this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive)); + self::assertFalse( + $this->commandsRunner->execPhpCommand($name, $this->io, interactive: $isInteractive, args: []), + ); } public static function provideInteractivityFlag(): iterable @@ -189,7 +193,12 @@ public function skipsNullCommands(): void $this->io->expects($this->never())->method('error'); $this->io->expects($this->once())->method('writeln')->with(' Skipped', $this->anything()); - self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true)); + self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true, args: [])); + } + + #[Test] + public function appendsProviedArgumentsToCommand(): void + { } private function createProcessMock(bool $isSuccessful): MockObject & Process From d1d8f0da6d6dc940d230a0dd66d1cbcc47aa4cc7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Sep 2023 09:21:08 +0200 Subject: [PATCH 7/8] Add tests for initial API key with value --- phpunit.xml.dist | 1 + test/Command/InitCommandTest.php | 31 +++++++++++++------ .../InstallationCommandsRunnerTest.php | 28 +++++++++++++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e457214..3f18413 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ bootstrap="./vendor/autoload.php" colors="true" cacheDirectory="build/.phpunit.cache" + displayDetailsOnTestsThatTriggerWarnings="true" > diff --git a/test/Command/InitCommandTest.php b/test/Command/InitCommandTest.php index 8db1967..fc12ce1 100644 --- a/test/Command/InitCommandTest.php +++ b/test/Command/InitCommandTest.php @@ -4,6 +4,7 @@ namespace ShlinkioTest\Shlink\Installer\Command; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -15,7 +16,6 @@ use Symfony\Component\Console\Tester\CommandTester; use function count; -use function Functional\contains; class InitCommandTest extends TestCase { @@ -34,13 +34,17 @@ public function setUp(): void } #[Test, DataProvider('provideInputs')] - public function expectedCommandsAreRunBasedOnInput(array $input, array $expectedCommands, bool $interactive): void + public function expectedCommandsAreRunBasedOnInput(array $input, array $commands, bool $interactive): void { - $this->commandsRunner->expects($this->exactly(count($expectedCommands)))->method('execPhpCommand')->with( - $this->callback(fn (string $commandName) => contains($expectedCommands, $commandName)), - $this->anything(), - $interactive, - )->willReturn(true); + $this->commandsRunner->expects($this->exactly(count($commands)))->method('execPhpCommand')->willReturnCallback( + function (string $commandName, $_, bool $isInteractive, array $args) use ($commands, $interactive): bool { + Assert::assertContains($commandName, $commands); + Assert::assertEquals($interactive, $isInteractive); + Assert::assertEquals($commandName === InstallationCommand::API_KEY_CREATE->value ? ['foo'] : [], $args); + + return true; + }, + ); $this->tester->execute($input, ['interactive' => $interactive]); } @@ -65,7 +69,7 @@ public static function provideInputs(): iterable ], true]; yield 'all' => [[ '--clear-db-cache' => true, - '--initial-api-key' => true, + '--initial-api-key' => null, '--download-rr-binary' => true, ], [ InstallationCommand::DB_CREATE_SCHEMA->value, @@ -77,7 +81,7 @@ public static function provideInputs(): iterable InstallationCommand::ROAD_RUNNER_BINARY_DOWNLOAD->value, ], true]; yield 'mixed' => [[ - '--initial-api-key' => true, + '--initial-api-key' => null, '--skip-download-geolite' => true, ], [ InstallationCommand::DB_CREATE_SCHEMA->value, @@ -85,6 +89,15 @@ public static function provideInputs(): iterable InstallationCommand::ORM_PROXIES->value, InstallationCommand::API_KEY_GENERATE->value, ], true]; + yield 'api key value' => [[ + '--initial-api-key' => 'foo', + ], [ + InstallationCommand::DB_CREATE_SCHEMA->value, + InstallationCommand::DB_MIGRATE->value, + InstallationCommand::ORM_PROXIES->value, + InstallationCommand::API_KEY_CREATE->value, + InstallationCommand::GEOLITE_DOWNLOAD_DB->value, + ], true]; } #[Test, DataProvider('provideExitCodes')] diff --git a/test/Service/InstallationCommandsRunnerTest.php b/test/Service/InstallationCommandsRunnerTest.php index be26b10..6eacebd 100644 --- a/test/Service/InstallationCommandsRunnerTest.php +++ b/test/Service/InstallationCommandsRunnerTest.php @@ -20,6 +20,7 @@ use function Functional\map; use function implode; use function sprintf; +use function str_contains; class InstallationCommandsRunnerTest extends TestCase { @@ -196,9 +197,32 @@ public function skipsNullCommands(): void self::assertTrue($this->commandsRunner->execPhpCommand($name, $this->io, interactive: true, args: [])); } - #[Test] - public function appendsProviedArgumentsToCommand(): void + #[Test, DataProvider('provideArgs')] + public function appendsProvidedArgumentsToCommand(array $args): void + { + $name = 'foo'; + $command = ['php', $name, 'something', ...$args]; + + $process = $this->createProcessMock(false); + $this->processHelper->expects($this->once())->method('run')->with( + $this->io, + $this->isInstanceOf(Process::class), + )->willReturn($process); + + $writeCallMatcher = $this->exactly(2); + $this->io->expects($writeCallMatcher)->method('write')->willReturnCallback( + fn (string $message) => $writeCallMatcher->numberOfInvocations() !== 2 + || str_contains(sprintf('Running "%s"', implode(' ', $command)), $message), + ); + + $this->commandsRunner->execPhpCommand($name, $this->io, interactive: false, args: $args); + } + + public static function provideArgs(): iterable { + yield 'no args' => [[]]; + yield 'one arg' => [['something']]; + yield 'multiple arg' => [['first', 'second', 'third']]; } private function createProcessMock(bool $isSuccessful): MockObject & Process From a10261df0e6cc4fdf92a0a407ee173dbdd945287 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 22 Sep 2023 09:41:29 +0200 Subject: [PATCH 8/8] Ensure random API key is generated during installation --- src/Command/AbstractInstallCommand.php | 5 ++++- test/Command/InstallCommandTest.php | 2 +- test/Command/UpdateCommandTest.php | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Command/AbstractInstallCommand.php b/src/Command/AbstractInstallCommand.php index 784056a..02aa003 100644 --- a/src/Command/AbstractInstallCommand.php +++ b/src/Command/AbstractInstallCommand.php @@ -74,11 +74,14 @@ private function execInitCommand(SymfonyStyle $io, ImportedConfig $importedConfi $input = [ InitOption::SKIP_INITIALIZE_DB->asCliFlag() => $isUpdate, InitOption::CLEAR_DB_CACHE->asCliFlag() => $isUpdate, - InitOption::INITIAL_API_KEY->asCliFlag() => ! $isUpdate, InitOption::DOWNLOAD_RR_BINARY->asCliFlag() => $isUpdate && $this->assetsHandler->roadRunnerBinaryExistsInPath($importedConfig->importPath), ]; + if (! $isUpdate) { + $input[InitOption::INITIAL_API_KEY->asCliFlag()] = null; + } + $command = $this->getApplication()?->find(InitCommand::NAME); $exitCode = $command?->run(new ArrayInput($input), $io); diff --git a/test/Command/InstallCommandTest.php b/test/Command/InstallCommandTest.php index 8905a30..aba51b0 100644 --- a/test/Command/InstallCommandTest.php +++ b/test/Command/InstallCommandTest.php @@ -57,7 +57,7 @@ public function commandIsExecutedAsExpected(): void $this->initCommand->expects($this->once())->method('run')->with( $this->callback(function (ArrayInput $input) { Assert::assertEquals( - '--skip-initialize-db --clear-db-cache --initial-api-key=1 --download-rr-binary', + '--skip-initialize-db --clear-db-cache --download-rr-binary --initial-api-key', $input->__toString(), ); return true; diff --git a/test/Command/UpdateCommandTest.php b/test/Command/UpdateCommandTest.php index 7ddeb55..a9e36bd 100644 --- a/test/Command/UpdateCommandTest.php +++ b/test/Command/UpdateCommandTest.php @@ -81,11 +81,11 @@ public static function provideCommands(): iterable { yield 'no rr binary' => [ false, - '--skip-initialize-db=1 --clear-db-cache=1 --initial-api-key --download-rr-binary', + '--skip-initialize-db=1 --clear-db-cache=1 --download-rr-binary', ]; yield 'rr binary' => [ true, - '--skip-initialize-db=1 --clear-db-cache=1 --initial-api-key --download-rr-binary=1', + '--skip-initialize-db=1 --clear-db-cache=1 --download-rr-binary=1', ]; } }