diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index 971df26a..c3d219c0 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -47,6 +47,19 @@ public function connect(): void PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]; + if ($this->config->sslRootCert !== null) { + $options[Pdo\Mysql::ATTR_SSL_CA] = $this->config->sslRootCert; + $options[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT] = $this->config->sslVerifyServerCert; + } + + if ($this->config->sslCert !== null) { + $options[Pdo\Mysql::ATTR_SSL_CERT] = $this->config->sslCert; + } + + if ($this->config->sslKey !== null) { + $options[Pdo\Mysql::ATTR_SSL_KEY] = $this->config->sslKey; + } + try { $this->pdo = $this->createPdo( $this->getDsn(), diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index e5490dc6..939287e7 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -21,19 +21,42 @@ function createTestDatabaseConfig( string $database = 'test', string $username = 'root', string $password = '', + ?string $sslCa = null, + bool $sslVerifyServerCert = false, + ?string $sslCert = null, + ?string $sslKey = null, ): DatabaseConfig { $tempDir = sys_get_temp_dir() . '/marko_mysql_test_' . uniqid(); mkdir($tempDir . '/config', recursive: true); + + $configArray = [ + 'driver' => 'mysql', + 'host' => $host, + 'port' => $port, + 'database' => $database, + 'username' => $username, + 'password' => $password, + ]; + + if ($sslCa !== null) { + $configArray['ssl_ca'] = $sslCa; + } + + if ($sslVerifyServerCert) { + $configArray['ssl_verify_server_cert'] = true; + } + + if ($sslCert !== null) { + $configArray['ssl_cert'] = $sslCert; + } + + if ($sslKey !== null) { + $configArray['ssl_key'] = $sslKey; + } + file_put_contents( $tempDir . '/config/database.php', - ' 'mysql', - 'host' => $host, - 'port' => $port, - 'database' => $database, - 'username' => $username, - 'password' => $password, - ], true) . ';', + 'toBe('success'); }); + it('passes SSL CA cert in PDO options when configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCa: '/path/to/ca.pem'); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_CA])->toBe('/path/to/ca.pem'); + }); + + it('sets SSL verify server cert when configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCa: '/path/to/ca.pem', sslVerifyServerCert: true); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_CA])->toBe('/path/to/ca.pem') + ->and($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeTrue(); + }); + + it('defaults SSL verify server cert to true when ssl_ca is set', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCa: '/path/to/ca.pem'); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeTrue(); + }); + + it('passes SSL client cert in PDO options when configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_CERT])->toBe('/path/to/client-cert.pem'); + }); + + it('passes SSL client key in PDO options when configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_KEY])->toBe('/path/to/client-key.pem'); + }); + + it('omits SSL client cert and key from PDO options when not configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions)->not->toHaveKey(Pdo\Mysql::ATTR_SSL_CERT) + ->and($capturedOptions)->not->toHaveKey(Pdo\Mysql::ATTR_SSL_KEY); + }); + + it('omits SSL CA cert from PDO options when not configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(); + + $connection = new class ($config, $capturedOptions) extends MySqlConnection + { + public function __construct( + DatabaseConfig $config, + private array &$capturedOptions, + ) { + parent::__construct($config); + } + + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $this->capturedOptions = $options; + + return new PDO('sqlite::memory:'); + } + }; + + $connection->connect(); + + expect($capturedOptions)->not->toHaveKey(Pdo\Mysql::ATTR_SSL_CA); + }); + it('prevents nested transactions (throws exception)', function (): void { $config = createTestDatabaseConfig(); $connection = new class ($config) extends MySqlConnection diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index bfef2343..0efdf44b 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -77,7 +77,25 @@ protected function createPdo( private function buildDsn(): string { - return "pgsql:host={$this->config->host};port={$this->config->port};dbname={$this->config->database}"; + $dsn = "pgsql:host={$this->config->host};port={$this->config->port};dbname={$this->config->database}"; + + if ($this->config->sslMode !== null) { + $dsn .= ";sslmode={$this->config->sslMode}"; + } + + if ($this->config->sslRootCert !== null) { + $dsn .= ";sslrootcert={$this->config->sslRootCert}"; + } + + if ($this->config->sslCert !== null) { + $dsn .= ";sslcert={$this->config->sslCert}"; + } + + if ($this->config->sslKey !== null) { + $dsn .= ";sslkey={$this->config->sslKey}"; + } + + return $dsn; } /** @@ -163,8 +181,10 @@ public function prepare( /** * @param array $bindings */ - private function bindValues(PDOStatement $statement, array $bindings): void - { + private function bindValues( + PDOStatement $statement, + array $bindings, + ): void { foreach ($bindings as $key => $value) { $param = is_int($key) ? $key + 1 : $key; $type = match (true) { diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 5ff479be..0a144520 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -21,19 +21,42 @@ function createTestPgSqlConfig( string $database = 'test', string $username = 'user', string $password = 'pass', + ?string $sslmode = null, + ?string $sslCa = null, + ?string $sslCert = null, + ?string $sslKey = null, ): DatabaseConfig { $tempDir = sys_get_temp_dir() . '/marko_pgsql_test_' . uniqid(); mkdir($tempDir . '/config', recursive: true); + + $configArray = [ + 'driver' => 'pgsql', + 'host' => $host, + 'port' => $port, + 'database' => $database, + 'username' => $username, + 'password' => $password, + ]; + + if ($sslmode !== null) { + $configArray['sslmode'] = $sslmode; + } + + if ($sslCa !== null) { + $configArray['ssl_ca'] = $sslCa; + } + + if ($sslCert !== null) { + $configArray['ssl_cert'] = $sslCert; + } + + if ($sslKey !== null) { + $configArray['ssl_key'] = $sslKey; + } + file_put_contents( $tempDir . '/config/database.php', - ' 'pgsql', - 'host' => $host, - 'port' => $port, - 'database' => $database, - 'username' => $username, - 'password' => $password, - ], true) . ';', + 'toBe('success'); }); + it('includes sslmode in DSN when configured', function (): void { + $config = createTestPgSqlConfig( + host: 'db.example.com', + port: 5432, + database: 'myapp', + sslmode: 'require', + ); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->toBe('pgsql:host=db.example.com;port=5432;dbname=myapp;sslmode=require'); + }); + + it('omits sslmode from DSN when not configured', function (): void { + $config = createTestPgSqlConfig( + host: 'db.example.com', + port: 5432, + database: 'myapp', + ); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->not->toContain('sslmode'); + }); + + it('includes sslrootcert in DSN when configured', function (): void { + $config = createTestPgSqlConfig( + host: 'db.example.com', + port: 5432, + database: 'myapp', + sslCa: '/path/to/ca.pem', + ); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->toContain('sslrootcert=/path/to/ca.pem'); + }); + + it('omits sslrootcert from DSN when not configured', function (): void { + $config = createTestPgSqlConfig( + host: 'db.example.com', + port: 5432, + database: 'myapp', + ); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->not->toContain('sslrootcert'); + }); + + it('includes sslcert in DSN when configured', function (): void { + $config = createTestPgSqlConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->toContain('sslcert=/path/to/client-cert.pem'); + }); + + it('omits sslcert from DSN when not configured', function (): void { + $config = createTestPgSqlConfig(); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->not->toContain('sslcert'); + }); + + it('includes sslkey in DSN when configured', function (): void { + $config = createTestPgSqlConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->toContain('sslkey=/path/to/client-key.pem'); + }); + + it('omits sslkey from DSN when not configured', function (): void { + $config = createTestPgSqlConfig(); + $connection = new PgSqlConnection($config); + + expect($connection->getDsn())->not->toContain('sslkey'); + }); + it('prevents nested transactions (throws exception)', function (): void { $config = createTestPgSqlConfig(); $connection = new class ($config) extends PgSqlConnection diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index 83ba2167..eccda9bb 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -24,6 +24,16 @@ public string $password; + public ?string $sslMode; + + public ?string $sslRootCert; + + public bool $sslVerifyServerCert; + + public ?string $sslCert; + + public ?string $sslKey; + /** * @throws ConfigurationException */ @@ -52,5 +62,18 @@ public function __construct( $this->database = $config['database']; $this->username = $config['username']; $this->password = $config['password']; + $this->sslMode = $config['sslmode'] ?? null; + $this->sslRootCert = $config['ssl_ca'] ?? null; + $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? ($this->sslRootCert !== null); + $this->sslCert = $config['ssl_cert'] ?? null; + $this->sslKey = $config['ssl_key'] ?? null; + + if ($this->sslCert !== null && $this->sslKey === null) { + throw ConfigurationException::incompleteSslKeyPair('ssl_cert', 'ssl_key'); + } + + if ($this->sslKey !== null && $this->sslCert === null) { + throw ConfigurationException::incompleteSslKeyPair('ssl_key', 'ssl_cert'); + } } } diff --git a/packages/database/src/Exceptions/ConfigurationException.php b/packages/database/src/Exceptions/ConfigurationException.php index 9a23555e..aae011e9 100644 --- a/packages/database/src/Exceptions/ConfigurationException.php +++ b/packages/database/src/Exceptions/ConfigurationException.php @@ -30,4 +30,15 @@ public static function missingRequiredKey( suggestion: "Add the '$key' key to your config/database.php file", ); } + + public static function incompleteSslKeyPair( + string $present, + string $missing, + ): self { + return new self( + message: "SSL configuration key '$present' is set but '$missing' is missing", + context: 'While validating database SSL configuration', + suggestion: "When using client certificate authentication, both 'ssl_cert' and 'ssl_key' must be provided together", + ); + } } diff --git a/packages/database/tests/DatabaseConfigTest.php b/packages/database/tests/DatabaseConfigTest.php index 6ad89f86..7c6821c3 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -141,6 +141,210 @@ } }); + it('loads optional SSL config when present', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'pgsql', + 'host' => 'db.example.com', + 'port' => 5432, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', + 'sslmode' => 'require', + 'ssl_ca' => '/path/to/ca.pem', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + $config = new DatabaseConfig($paths); + + expect($config->sslMode)->toBe('require') + ->and($config->sslRootCert)->toBe('/path/to/ca.pem') + ->and($config->sslVerifyServerCert)->toBeTrue(); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + + it('loads ssl_verify_server_cert when present', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'mysql', + 'host' => 'db.example.com', + 'port' => 3306, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', + 'ssl_ca' => '/path/to/ca.pem', + 'ssl_verify_server_cert' => true, +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + $config = new DatabaseConfig($paths); + + expect($config->sslRootCert)->toBe('/path/to/ca.pem') + ->and($config->sslVerifyServerCert)->toBeTrue(); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + + it('loads ssl_cert and ssl_key when present', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'pgsql', + 'host' => 'db.example.com', + 'port' => 5432, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', + 'ssl_cert' => '/path/to/client-cert.pem', + 'ssl_key' => '/path/to/client-key.pem', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + $config = new DatabaseConfig($paths); + + expect($config->sslCert)->toBe('/path/to/client-cert.pem') + ->and($config->sslKey)->toBe('/path/to/client-key.pem'); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + + it('defaults SSL config to null when not present', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + $config = new DatabaseConfig($paths); + + expect($config->sslMode)->toBeNull() + ->and($config->sslRootCert)->toBeNull() + ->and($config->sslVerifyServerCert)->toBeFalse() + ->and($config->sslCert)->toBeNull() + ->and($config->sslKey)->toBeNull(); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + + it('throws ConfigurationException when ssl_cert is set without ssl_key', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', + 'ssl_cert' => '/path/to/client-cert.pem', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + expect(fn () => new DatabaseConfig($paths)) + ->toThrow(ConfigurationException::class) + ->and(fn () => new DatabaseConfig($paths)) + ->toThrow(ConfigurationException::class, 'ssl_key'); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + + it('throws ConfigurationException when ssl_key is set without ssl_cert', function (): void { + $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); + $configDir = $tempDir . '/config'; + mkdir($configDir, 0755, true); + + $configContent = <<<'PHP' + 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'database' => 'test_db', + 'username' => 'root', + 'password' => 'secret', + 'ssl_key' => '/path/to/client-key.pem', +]; +PHP; + file_put_contents($configDir . '/database.php', $configContent); + + try { + $paths = new ProjectPaths($tempDir); + expect(fn () => new DatabaseConfig($paths)) + ->toThrow(ConfigurationException::class) + ->and(fn () => new DatabaseConfig($paths)) + ->toThrow(ConfigurationException::class, 'ssl_cert'); + } finally { + unlink($configDir . '/database.php'); + rmdir($configDir); + rmdir($tempDir); + } + }); + it('throws ConfigurationException when required keys missing', function (): void { $tempDir = sys_get_temp_dir() . '/marko_test_' . uniqid(); $configDir = $tempDir . '/config';