From 325d204332e828a695ffa0eb21e368d544396ed8 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Tue, 4 Jun 2019 23:51:37 +0200 Subject: [PATCH] added installation command (#838) --- .codecov.yml | 2 +- src/Command/CreateReleaseCommand.php | 1 - src/Command/InstallCommand.php | 289 +++++++++++++++++++++ src/Command/ResetCommand.php | 13 +- src/Doctrine/UTCDateTimeType.php | 5 + src/Utils/File.php | 29 +++ tests/Command/CreateUserCommandTest.php | 3 +- tests/Command/InstallCommandTest.php | 93 +++++++ tests/Controller/UserControllerTest.php | 1 + tests/Doctrine/TimesheetSubscriberTest.php | 36 +++ tests/Doctrine/UTCDateTimeTypeTest.php | 66 +++-- tests/Utils/FileTest.php | 36 +++ 12 files changed, 542 insertions(+), 32 deletions(-) create mode 100644 src/Command/InstallCommand.php create mode 100644 src/Utils/File.php create mode 100644 tests/Command/InstallCommandTest.php create mode 100644 tests/Doctrine/TimesheetSubscriberTest.php create mode 100644 tests/Utils/FileTest.php diff --git a/.codecov.yml b/.codecov.yml index 8a754d20..0ec2aaf3 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -13,7 +13,7 @@ coverage: threshold: 0.5% patch: default: - threshold: 0.5% + threshold: 50% changes: no parsers: diff --git a/src/Command/CreateReleaseCommand.php b/src/Command/CreateReleaseCommand.php index 971d213e..60db05d4 100644 --- a/src/Command/CreateReleaseCommand.php +++ b/src/Command/CreateReleaseCommand.php @@ -114,7 +114,6 @@ class CreateReleaseCommand extends Command $commands = [ 'Clone repository' => $gitCmd . ' ' . $tmpDir, 'Install composer dependencies' => 'cd ' . $tmpDir . ' && composer install --no-dev --optimize-autoloader', - 'Create .env file' => 'cd ' . $tmpDir . ' && cp .env.dist .env', 'Create database' => 'cd ' . $tmpDir . ' && bin/console doctrine:database:create -n', 'Create tables' => 'cd ' . $tmpDir . ' && bin/console doctrine:schema:create -n', 'Add all migrations' => 'cd ' . $tmpDir . ' && bin/console doctrine:migrations:version --add --all -n', diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php new file mode 100644 index 00000000..b152096e --- /dev/null +++ b/src/Command/InstallCommand.php @@ -0,0 +1,289 @@ +rootDir = $projectDirectory; + $this->connection = $connection; + $this->file = $files; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName(self::$defaultName) + ->setDescription('Basic installation for Kimai') + ->setHelp('This command will perform the basic installation steps to get Kimai up and running.') + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $io->title('Welcome to the interactive Kimai installer!'); + + if (!$input->isInteractive()) { + $io->error('Installation only works in interactive mode'); + + return self::ERROR_INTERACTIVE; + } + + $rows = $this->checkPermissions(); + $result = $this->confirmAbortToReviewPermissions($io, $input, $output, $rows); + if (true !== $result) { + return $result; + } + + // we cannot change the environment here, as it needs to be configured in the .env file before this command is started + // $environment = $io->choice('Which environment should be used ("dev" is only for testing and imports demo data)?', ['dev', 'production'], 'production'); + // $io->note(sprintf('You have chosen the "%s" environment', $environment)); + $environment = getenv('APP_ENV'); + + // create database if necessary + try { + $this->createDatabase($io, $input, $output); + } catch (\Exception $ex) { + $io->error('Failed to create database: ' . $ex->getMessage()); + + return self::ERROR_DATABASE; + } + + try { + $this->createSchema($io, $input, $output); + } catch (\Exception $ex) { + $io->error('Failed to create database schema: ' . $ex->getMessage()); + + return self::ERROR_SCHEMA; + } + + // initialize database with proper migration status + try { + $this->importMigrations($io, $output); + } catch (\Exception $ex) { + $io->error('Failed to set migration status: ' . $ex->getMessage()); + + return self::ERROR_MIGRATIONS; + } + + $this->rebuildCaches($environment, $io, $input, $output); + + $io->success( + 'Congratulations! ' . Constants::SOFTWARE . ' (' . Constants::VERSION . ' ' . Constants::STATUS . ') was successful installed!' + ); + + return 0; + } + + protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output) + { + if (!$this->askConfirmation($input, $output, 'Do you want me to rebuild the caches (yes) or skip this step (no)?', true)) { + return; + } + + $io->text('Rebuilding your cache now, please be patient ...'); + + $command = $this->getApplication()->find('cache:clear'); + try { + $command->run(new ArrayInput(['--env' => $environment]), $output); + } catch (\Exception $ex) { + $io->error('Failed to clear cache: ' . $ex->getMessage()); + + return self::ERROR_CACHE_CLEAN; + } + + $command = $this->getApplication()->find('cache:warmup'); + try { + $command->run(new ArrayInput(['--env' => $environment]), $output); + } catch (\Exception $ex) { + $io->error('Failed to clear cache: ' . $ex->getMessage()); + + return self::ERROR_CACHE_WARMUP; + } + } + + protected function checkPermissions(): array + { + $directories = [ + 'var/cache/', + 'var/data/', + 'var/log/', + 'var/plugins/', + 'var/sessions/', + ]; + + $rows = []; + + foreach ($directories as $directory) { + $absDir = rtrim($this->rootDir) . DIRECTORY_SEPARATOR . $directory; + $perms = $this->file->getPermissions($absDir); + $reason = []; + if (!($perms & 0x0100)) { + $reason[] = 'read owner'; + } + if (!($perms & 0x0080)) { + $reason[] = 'write owner'; + } + if (!($perms & 0x0020)) { + $reason[] = 'read group'; + } + if (!($perms & 0x0010)) { + $reason[] = 'write group'; + } + + if (!empty($reason)) { + $rows[] = [$directory, 'missing: ' . implode(',', $reason)]; + } elseif (!is_writable($absDir)) { + $rows[] = [$directory, 'Directory not writable']; + } + } + + return $rows; + } + + protected function confirmAbortToReviewPermissions(SymfonyStyle $io, InputInterface $input, OutputInterface $output, array $permissions) + { + if (empty($permissions)) { + return true; + } + + $question = 'Kimai found file permissions which look incorrect.' . + ' More information is available at https://www.kimai.org/documentation/installation.html.' . + ' If you are sure that all directories can be written by the webserver, you can continue.' . + ' Otherwise it is recommended to abort the installation and check them first.'; + + $io->caution($question); + + $io->table(['Directory', 'Permission'], $permissions); + + if (!$this->askConfirmation($input, $output, 'Continue with the installation (yes) or review permissions first (no)?', false)) { + $io->warning('Aborting installation to review the permissions for above mentioned directories'); + + return self::ERROR_PERMISSIONS; + } + $io->writeln(''); + } + + protected function importMigrations(SymfonyStyle $io, OutputInterface $output) + { + if ($this->connection->getSchemaManager()->tablesExist(['migration_versions'])) { + $amount = $this->connection->executeQuery('SELECT count(*) as counter FROM migration_versions')->fetchColumn(0); + if ($amount > 0) { + $io->note(sprintf('Found %s migrations in your database, skipping import', $amount)); + + return; + } + } + + $command = $this->getApplication()->find('doctrine:migrations:version'); + $cmdInput = new ArrayInput(['--add' => true, '--all' => true]); + $cmdInput->setInteractive(false); + $command->run($cmdInput, $output); + + $io->writeln(''); + } + + protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output) + { + if ($this->connection->isConnected()) { + $io->note(sprintf('Database is existing and connection could be established')); + + return; + } + + if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) { + throw new \Exception('Skipped database creation, aborting installation'); + } + + $command = $this->getApplication()->find('doctrine:database:create'); + $command->run(new ArrayInput([]), $output); + } + + protected function createSchema(SymfonyStyle $io, InputInterface $input, OutputInterface $output) + { + if (!$this->connection->isConnected() && !$this->connection->connect()) { + throw new \Exception(sprintf('Cannot create tables in database "%s", connection could not be established', $this->connection->getDatabase())); + } + + if ($this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) { + $io->note('It seems as if you already have the required tables in your database, skipping schema creation'); + + return; + } + + $command = $this->getApplication()->find('doctrine:schema:create'); + $command->run(new ArrayInput([]), $output); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @param string $question + * @param bool $default + * @return bool + */ + private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false) + { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelperSet()->get('question'); + $text = sprintf('%s (yes/no) [%s]:', $question, $default ? 'yes' : 'no'); + $question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i'); + + return $questionHelper->ask($input, $output, $question); + } +} diff --git a/src/Command/ResetCommand.php b/src/Command/ResetCommand.php index 716a7890..ab002a9d 100644 --- a/src/Command/ResetCommand.php +++ b/src/Command/ResetCommand.php @@ -103,6 +103,17 @@ EOT return 4; } + try { + $command = $this->getApplication()->find('doctrine:migrations:version'); + $cmdInput = new ArrayInput(['--add' => true, '--all' => true]); + $cmdInput->setInteractive(false); + $command->run($cmdInput, $output); + } catch (\Exception $ex) { + $io->error('Failed to set migration status: ' . $ex->getMessage()); + + return 5; + } + if (!$input->getOption('no-cache')) { $command = $this->getApplication()->find('cache:clear'); try { @@ -110,7 +121,7 @@ EOT } catch (\Exception $ex) { $io->error('Failed to clear cache: ' . $ex->getMessage()); - return 5; + return 6; } } diff --git a/src/Doctrine/UTCDateTimeType.php b/src/Doctrine/UTCDateTimeType.php index 61b12126..d85d1e3f 100644 --- a/src/Doctrine/UTCDateTimeType.php +++ b/src/Doctrine/UTCDateTimeType.php @@ -72,4 +72,9 @@ class UTCDateTimeType extends DateTimeType return $converted; } + + public function requiresSQLCommentHint(AbstractPlatform $platform) + { + return true; + } } diff --git a/src/Utils/File.php b/src/Utils/File.php new file mode 100644 index 00000000..ff10bbd3 --- /dev/null +++ b/src/Utils/File.php @@ -0,0 +1,29 @@ +getContainer(); $user = $container->get('doctrine')->getRepository(User::class)->loadUserByUsername('MyTestUser'); - $this->assertNotNull($user); + self::assertInstanceOf(User::class, $user); + self::assertNotNull($user); } protected function createUser($username, $email, $role, $password) diff --git a/tests/Command/InstallCommandTest.php b/tests/Command/InstallCommandTest.php new file mode 100644 index 00000000..6eb3eb08 --- /dev/null +++ b/tests/Command/InstallCommandTest.php @@ -0,0 +1,93 @@ +getMockBuilder(File::class)->setMethods(['getPermissions'])->getMock(); + $fileMock->expects($this->exactly(5))->method('getPermissions')->willReturn($permission); + + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + $container = self::$kernel->getContainer(); + + $this->application->add(new InstallCommand( + $container->getParameter('kernel.project_dir'), + $container->get('doctrine')->getConnection(), + $fileMock + )); + + return $this->application->find('kimai:install'); + } + + public function testMissingPermissionsAborted() + { + $command = $this->getCommand(0210); + $commandTester = new CommandTester($command); + $commandTester->setInputs(['no']); + $commandTester->execute([ + 'command' => $command->getName(), + ]); + + $result = $commandTester->getDisplay(); + + self::assertContains('var/cache/', $result); + self::assertContains('var/data/', $result); + self::assertContains('var/log/', $result); + self::assertContains('var/plugins/', $result); + self::assertContains('var/sessions/', $result); + self::assertEquals(5, substr_count($result, 'missing: read owner,read group,write group')); + self::assertContains('[WARNING] Aborting installation to review the permissions for above mentioned', $result); + self::assertEquals(InstallCommand::ERROR_PERMISSIONS, $commandTester->getStatusCode()); + } + + public function testFullRunWithEverythingPreInstalled() + { + $command = $this->getCommand(0770); + $commandTester = new CommandTester($command); + $commandTester->setInputs(['no']); + $commandTester->execute([ + 'command' => $command->getName(), + ]); + + $result = $commandTester->getDisplay(); + + // create database is skipped + self::assertContains('[NOTE] Database is existing and connection could be established', $result); + + // create schema is skipped + self::assertContains('[NOTE] It seems as if you already have the required tables in your database,', $result); + self::assertContains('skipping schema creation', $result); + + self::assertContains('[NOTE] Found ', $result); + self::assertContains(' migrations in your database, skipping import', $result); + + self::assertContains('[OK] Congratulations! Kimai 2 (0.9 stable) was successful installed!', $result); + + self::assertEquals(0, $commandTester->getStatusCode()); + } +} diff --git a/tests/Controller/UserControllerTest.php b/tests/Controller/UserControllerTest.php index 7735a33d..70a3d100 100644 --- a/tests/Controller/UserControllerTest.php +++ b/tests/Controller/UserControllerTest.php @@ -39,6 +39,7 @@ class UserControllerTest extends ControllerBaseTest $this->assertAccessIsGranted($client, '/admin/user/create'); $form = $client->getCrawler()->filter('form[name=user_create]')->form(); $this->assertTrue($form->has('user_create[create_more]')); + $this->assertFalse($form->get('user_create[create_more]')->hasValue()); $this->assertNull($form->get('user_create[create_more]')->getValue()); $client->submit($form, [ 'user_create' => [ diff --git a/tests/Doctrine/TimesheetSubscriberTest.php b/tests/Doctrine/TimesheetSubscriberTest.php new file mode 100644 index 00000000..4b8b6369 --- /dev/null +++ b/tests/Doctrine/TimesheetSubscriberTest.php @@ -0,0 +1,36 @@ +getSubscribedEvents(); + $this->assertTrue(in_array(Events::onFlush, $events)); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Invalid TimesheetCalculator implementation given. Expected CalculatorInterface but received stdClass + */ + public function testConstructThrowsExceptionOnInvalidParam() + { + new TimesheetSubscriber([new \stdClass()]); + } +} diff --git a/tests/Doctrine/UTCDateTimeTypeTest.php b/tests/Doctrine/UTCDateTimeTypeTest.php index 061a10f0..02e8b7b0 100644 --- a/tests/Doctrine/UTCDateTimeTypeTest.php +++ b/tests/Doctrine/UTCDateTimeTypeTest.php @@ -11,6 +11,8 @@ namespace App\Tests\Doctrine; use App\Doctrine\UTCDateTimeType; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySqlPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Types\Type; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -19,24 +21,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; */ class UTCDateTimeTypeTest extends KernelTestCase { - /** - * @var AbstractPlatform - */ - private $platform; - - /** - * {@inheritdoc} - */ - protected function setUp() - { - $kernel = self::bootKernel(); - - $registry = $kernel->getContainer()->get('doctrine'); - /** @var \Doctrine\DBAL\Connection $connection */ - $connection = $registry->getConnection(); - $this->platform = $connection->getDatabasePlatform(); - } - public function testGetUtc() { Type::overrideType(Type::DATETIME, UTCDateTimeType::class); @@ -49,13 +33,16 @@ class UTCDateTimeTypeTest extends KernelTestCase $this->assertEquals('UTC', $type::getUtc()->getName()); } - public function testConvertToDatabaseValue() + /** + * @dataProvider getPlatforms + */ + public function testConvertToDatabaseValue(AbstractPlatform $platform) { Type::overrideType(Type::DATETIME, UTCDateTimeType::class); /** @var UTCDateTimeType $type */ $type = Type::getType(Type::DATETIME); - $result = $type->convertToDatabaseValue(null, $this->platform); + $result = $type->convertToDatabaseValue(null, $platform); $this->assertNull($result); $berlinTz = new \DateTimeZone('Europe/Berlin'); @@ -66,40 +53,63 @@ class UTCDateTimeTypeTest extends KernelTestCase $expected = clone $date; $expected->setTimezone($type::getUtc()); - $bla = $expected->format($this->platform->getDateTimeFormatString()); + $bla = $expected->format($platform->getDateTimeFormatString()); /** @var \DateTime $result */ - $result = $type->convertToDatabaseValue($date, $this->platform); + $result = $type->convertToDatabaseValue($date, $platform); $this->assertEquals($bla, $result); } - public function testConvertToPHPValue() + /** + * @dataProvider getPlatforms + */ + public function testConvertToPHPValue(AbstractPlatform $platform) { Type::overrideType(Type::DATETIME, UTCDateTimeType::class); /** @var UTCDateTimeType $type */ $type = Type::getType(Type::DATETIME); - $result = $type->convertToPHPValue(null, $this->platform); + $result = $type->convertToPHPValue(null, $platform); $this->assertNull($result); - $result = $type->convertToPHPValue('2019-01-17 13:30:00', $this->platform); + $result = $type->convertToPHPValue('2019-01-17 13:30:00', $platform); $this->assertInstanceOf(\DateTime::class, $result); $this->assertEquals('UTC', $result->getTimezone()->getName()); - $result = $result->format($this->platform->getDateTimeFormatString()); + $result = $result->format($platform->getDateTimeFormatString()); $this->assertEquals('2019-01-17 13:30:00', $result); } /** + * @dataProvider getPlatforms * @expectedException \Doctrine\DBAL\Types\ConversionException */ - public function testConvertToPHPValueWithInvalidValue() + public function testConvertToPHPValueWithInvalidValue(AbstractPlatform $platform) { Type::overrideType(Type::DATETIME, UTCDateTimeType::class); /** @var UTCDateTimeType $type */ $type = Type::getType(Type::DATETIME); - $type->convertToPHPValue('201xx01-17 13:30:00', $this->platform); + $type->convertToPHPValue('201xx01-17 13:30:00', $platform); + } + + /** + * @dataProvider getPlatforms + */ + public function testRequiresSQLCommentHint(AbstractPlatform $platform) + { + Type::overrideType(Type::DATETIME, UTCDateTimeType::class); + /** @var UTCDateTimeType $type */ + $type = Type::getType(Type::DATETIME); + self::assertTrue($type->requiresSQLCommentHint($platform)); + } + + public function getPlatforms() + { + return [ + [new MySqlPlatform()], + [new SqlitePlatform()], + ]; } } diff --git a/tests/Utils/FileTest.php b/tests/Utils/FileTest.php new file mode 100644 index 00000000..5b3210c9 --- /dev/null +++ b/tests/Utils/FileTest.php @@ -0,0 +1,36 @@ +getPermissions('/kjhgkjhg/jkhgkjhg'); + } + + public function testGetPermissionsOnDisallowedDirectory() + { + $sut = new File(); + $perms = $sut->getPermissions(__FILE__); + $this->assertEquals($perms, fileperms(__FILE__)); + } +}