diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebcfb6c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: [push] + +jobs: + build-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: php-actions/composer@v5 + + - name: PHPUnit Tests + uses: php-actions/phpunit@v2 + with: + bootstrap: vendor/autoload.php + configuration: phpunit.xml.dist diff --git a/.gitignore b/.gitignore index d1502b0..bf68767 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ vendor/ composer.lock +phpunit.xml +.phpunit.cache/ diff --git a/composer.json b/composer.json index 935ef91..6882dc7 100644 --- a/composer.json +++ b/composer.json @@ -15,12 +15,13 @@ } }, "suggest": { - "illuminate/database": "Needed for DatabaseChecker", + "ext-pdo": "Needed for DatabaseChecker", "php-amqplib/php-amqplib": "Needed for RabbitMqChecker", "influxdb/influxdb-php": "Needed for InfluxDbChecker", "predis/predis": "Needed for RedisChecker" }, "require-dev": { - "squizlabs/php_codesniffer": "^2.7" + "squizlabs/php_codesniffer": "^2.7", + "phpunit/phpunit": "^9.5" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..21d3ddc --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Services/DatabaseChecker.php b/src/Services/DatabaseChecker.php index 535ee05..6bd0e45 100644 --- a/src/Services/DatabaseChecker.php +++ b/src/Services/DatabaseChecker.php @@ -3,10 +3,15 @@ namespace Nordsec\StatusChecker\Services; use Exception; -use Illuminate\Database\Capsule\Manager as Capsule; class DatabaseChecker implements StatusCheckerInterface { + protected const DEFAULT_DRIVER = 'mysql'; + protected const DEFAULT_HOST = 'localhost'; + protected const DEFAULT_PORT = 3306; + protected const DEFAULT_USER = 'root'; + protected const DEFAULT_PASSWORD = ''; + private $name; private $configuration; private $critical = true; @@ -41,15 +46,145 @@ public function needsOutput(): bool public function checkStatus(): string { - $capsule = new Capsule(); - $capsule->addConnection($this->configuration); - try { - $capsule->getConnection()->select('select 1'); + $this->executeSelect('select 1'); } catch (Exception $exception) { return StatusCheckerInterface::STATUS_FAIL; } return StatusCheckerInterface::STATUS_OK; } + + protected function createConnection(string $dsn, $user = null, $password = null, array $options = []): \PDO + { + return new \PDO($dsn, $user, $password, $options); + } + + private function executeSelect(string $query): void + { + $config = $this->resolveConfig(); + + $dsn = $this->resolveDsn($config); + $user = $this->resolveUser($config); + $pass = $this->resolvePassword($config); + + $pdo = $this->createConnection($dsn, $user, $pass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + + $pdo->exec($query); + } + + private function resolveDsn(array $config): string + { + if ( + is_string($this->configuration) && + strpos($this->configuration, ':') !== false && + strpos($this->configuration, ':') !== strpos($this->configuration, '://') + ) { + return $this->configuration; + } + + return $config['dsn'] ?? $this->buildDsn($config); + } + + private function resolveConfig(): array + { + if ( + is_string($this->configuration) && + strpos($this->configuration, '://') !== false && + strpos($this->configuration, '://') === strpos($this->configuration, ':') + ) { + return $this->resolveConfigFromUrl($this->configuration); + } + + if (is_array($this->configuration)) { + if (isset($this->configuration['url'])) { + return $this->resolveConfigFromUrl($this->configuration['url']); + } + if (isset($this->configuration['driver']) || isset($this->configuration['adapter'])) { + return $this->configuration; + } + + $firstElement = reset($this->configuration); + if (isset($firstElement['url'])) { + return $this->resolveConfigFromUrl($firstElement['url']); + } + if (isset($firstElement['driver']) || isset($firstElement['adapter'])) { + return $firstElement; + } + } + + $urlFromEnv = getenv('DATABASE_URL'); + if (!empty($urlFromEnv)) { + return $this->resolveConfigFromUrl($urlFromEnv); + } + + return []; + } + + private function buildDsn(array $config): string + { + if (($config['driver'] ?? '') === 'sqlite') { + return sprintf('%s:%s', $config['driver'], $config['database'] ?? $config['path']); + } + + return sprintf( + '%s:host=%s;port=%d;dbname=%s', + $this->resolveDriver($config), + $config['host'] ?? $config['read']['host'] ?? $config['write']['host'] ?? static::DEFAULT_HOST, + $config['port'] ?? static::DEFAULT_PORT, + $config['dbname'] ?? $config['database'] ?? null, + ); + } + + private function resolveConfigFromUrl(string $url): array + { + $details = parse_url($url); + $additionalPathChars = 1; + + if ($details === false) { + // $url is in format sqlite:///path/to/file + preg_match('|^(?\w+)://(?.*)$|', $url, $details); + $additionalPathChars = 0; + } + + $result = [ + 'driver' => $details['scheme'], + 'user' => $details['user'] ?? '', + 'password' => $details['pass'] ?? '', + 'database' => substr($details['path'] ?? null, $additionalPathChars), + 'host' => $details['host'] ?? '', + ]; + + if (empty($result['database']) && !empty($result['host']) && $result['driver'] === 'sqlite') { + // $url is in format sqlite://file_in_current_dir + $result['database'] = $result['host']; + $result['host'] = ''; + } + + return $result; + } + + private function resolveUser(array $config): string + { + return $config['user'] ?? $config['username'] ?? $config['dbuser'] ?? static::DEFAULT_USER; + } + + private function resolvePassword(array $config): string + { + return $config['password'] ?? $config['pass'] ?? $config['dbpass'] ?? static::DEFAULT_PASSWORD; + } + + private function resolveDriver(array $config): string + { + $driver = $config['driver'] ?? $config['adapter'] ?? static::DEFAULT_DRIVER; + + if ($driver === 'mysqli') { + $driver = 'mysql'; + } + if (strpos($driver, 'pdo_') !== false) { + $driver = str_replace('pdo_', '', $driver); + } + + return $driver; + } } diff --git a/tests/Unit/Services/DatabaseCheckerTest.php b/tests/Unit/Services/DatabaseCheckerTest.php new file mode 100644 index 0000000..c1ae216 --- /dev/null +++ b/tests/Unit/Services/DatabaseCheckerTest.php @@ -0,0 +1,280 @@ +getMockBuilder(DatabaseChecker::class) + ->setConstructorArgs($arguments) + ->onlyMethods(['createConnection']) + ->getMock() + ; + + $databaseCheckerMock + ->expects(static::once()) + ->method('createConnection') + ->with(...$expectedPdoArgs) + ->willReturn($this->createMock(\PDO::class)) + ; + + $actualResult = $databaseCheckerMock->checkStatus(); + + static::assertSame($expectedResult, $actualResult); + } + + public function getTestCheckStatusCases(): array + { + $cases = []; + + $cases['doctrine_mysql_url'] = [ + 'arguments' => [ + 'database.mysql_doctrine', + [ + 'url' => 'mysql://user:pass@localhost:3306/testdb', + ], + ], + 'expectedPdoArgs' => [ + 'mysql:host=localhost;port=3306;dbname=testdb', + 'user', + 'pass', + [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION], + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['eloquent_mysql_url'] = [ + 'arguments' => [ + 'database.mysql_eloquent', + [ + 'my_connection' => [ + 'url' => 'mysql://user2:pass2@127.0.0.1:3306/testdb2', + ], + ], + ], + 'expectedPdoArgs' => [ + 'mysql:host=127.0.0.1;port=3306;dbname=testdb2', + 'user2', + 'pass2', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['custom_mysql_url'] = [ + 'arguments' => [ + 'database.mysql_custom', + 'mysql://user3:pass3@127.0.0.3:3306/testdb3', + ], + 'expectedPdoArgs' => [ + 'mysql:host=127.0.0.3;port=3306;dbname=testdb3', + 'user3', + 'pass3', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['doctrine_mysql_parameters'] = [ + 'arguments' => [ + 'database.mysql_doctrine', + [ + 'dbname' => 'database', + 'host' => 'localhost', + 'port' => '1234', + 'user' => 'user', + 'password' => 'secret', + 'driver' => 'pdo_mysql', + ], + ], + 'expectedPdoArgs' => [ + 'mysql:host=localhost;port=1234;dbname=database', + 'user', + 'secret', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['eloquent_mysql_parameters'] = [ + 'arguments' => [ + 'database.mysql_eloquent', + [ + 'mysql' => [ + 'read' => [ + 'host' => '192.168.1.1', + ], + 'write' => [ + 'host' => '196.168.1.2', + ], + 'driver' => 'mysql', + 'database' => 'database', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + ], + ], + ], + 'expectedPdoArgs' => [ + 'mysql:host=192.168.1.1;port=3306;dbname=database', + 'root', + '', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['propel_mysql_parameters'] = [ + 'arguments' => [ + 'database.mysql_propel', + [ + 'bookstore' => [ + 'adapter' => 'mysql', + 'classname' => 'Propel\Runtime\Connection\ConnectionWrapper', + 'dsn' => 'mysql:host=localhost;dbname=my_db_name', + 'user' => 'my_db_user', + 'password' => 's3cr3t', + 'attributes' => [], + ], + ], + ], + 'expectedPdoArgs' => [ + 'mysql:host=localhost;dbname=my_db_name', + 'my_db_user', + 's3cr3t', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['doctrine_sqlite_url_current_dir'] = [ + 'arguments' => [ + 'database.sqlite_doctrine', + [ + 'url' => 'sqlite://testfile.db', + ], + ], + 'expectedPdoArgs' => [ + 'sqlite:testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['eloquent_sqlite_url_current_dir'] = [ + 'arguments' => [ + 'database.sqlite_eloquent', + [ + 'sqlite' => [ + 'url' => 'sqlite://testfile.db', + ], + ], + ], + 'expectedPdoArgs' => [ + 'sqlite:testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['doctrine_sqlite_url_realpath'] = [ + 'arguments' => [ + 'database.sqlite_doctrine', + [ + 'url' => 'sqlite:///var/testfile.db', + ], + ], + 'expectedPdoArgs' => [ + 'sqlite:/var/testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['eloquent_sqlite_url_realpath'] = [ + 'arguments' => [ + 'database.sqlite_eloquent', + [ + 'sqlite' => [ + 'url' => 'sqlite:///var/testfile.db', + ], + ], + ], + 'expectedPdoArgs' => [ + 'sqlite:/var/testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['custom_sqlite_url'] = [ + 'arguments' => [ + 'database.sqlite_custom', + 'sqlite:///var/testfile.db', + ], + 'expectedPdoArgs' => [ + 'sqlite:/var/testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['custom_sqlite_dsn'] = [ + 'arguments' => [ + 'database.sqlite_custom', + 'sqlite:/var/testfile.db', + ], + 'expectedPdoArgs' => [ + 'sqlite:/var/testfile.db', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + ]; + + $cases['symfony_style_database_url'] = [ + 'arguments' => [ + 'database.mysql_symfony', + [], + ], + 'expectedPdoArgs' => [ + 'mysql:host=localhost;port=3306;dbname=test_sf_db', + 'user1', + 'pass1', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + 'env' => [ + 'DATABASE_URL=mysql://user1:pass1@localhost/test_sf_db', + ], + ]; + + $cases['defaults_only'] = [ + 'arguments' => [ + 'database.mysql_defaults', + [], + ], + 'expectedPdoArgs' => [ + 'mysql:host=localhost;port=3306;dbname=', + 'root', + '', + ], + 'expectedResult' => DatabaseChecker::STATUS_OK, + 'env' => [ + 'DATABASE_URL=', + ], + ]; + + return $cases; + } +}