From 7a9f520dab8272cc720e1d785f1eb118cf8a2ef1 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 18:59:46 +0100 Subject: [PATCH 1/9] feat: ssl for database connections --- .../src/Connection/MySqlConnection.php | 4 + .../tests/Connection/MySqlConnectionTest.php | 84 +++++++++++++++++-- .../src/Connection/PgSqlConnection.php | 12 ++- .../tests/Connection/PgSqlConnectionTest.php | 75 +++++++++++++++-- .../database/src/Config/DatabaseConfig.php | 6 ++ .../database/tests/DatabaseConfigTest.php | 66 +++++++++++++++ 6 files changed, 230 insertions(+), 17 deletions(-) diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index 971df26a..8fbb0151 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -47,6 +47,10 @@ public function connect(): void PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]; + if ($this->config->sslRootCert !== null) { + $options[PDO::MYSQL_ATTR_SSL_CA] = $this->config->sslRootCert; + } + 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..a2dd3621 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -21,19 +21,27 @@ function createTestDatabaseConfig( string $database = 'test', string $username = 'root', string $password = '', + ?string $sslCa = 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; + } + 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('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..29da48d2 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -77,7 +77,17 @@ 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}"; + } + + return $dsn; } /** diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 5ff479be..8348aac5 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -21,19 +21,32 @@ function createTestPgSqlConfig( string $database = 'test', string $username = 'user', string $password = 'pass', + ?string $sslmode = null, + ?string $sslCa = 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; + } + 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('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..b7effff8 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -24,6 +24,10 @@ public string $password; + public ?string $sslMode; + + public ?string $sslRootCert; + /** * @throws ConfigurationException */ @@ -52,5 +56,7 @@ 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; } } diff --git a/packages/database/tests/DatabaseConfigTest.php b/packages/database/tests/DatabaseConfigTest.php index 6ad89f86..3b34fdb2 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -141,6 +141,72 @@ } }); + 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'); + } 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(); + } 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'; From 6839b761e93c93315cf2936fd7592b7156be48d3 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 22:07:31 +0100 Subject: [PATCH 2/9] feat: verify server ssl config for mysql --- .../src/Connection/MySqlConnection.php | 1 + .../tests/Connection/MySqlConnectionTest.php | 66 +++++++++++++++++++ .../database/src/Config/DatabaseConfig.php | 3 + .../database/tests/DatabaseConfigTest.php | 40 ++++++++++- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index 8fbb0151..1a679d24 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -49,6 +49,7 @@ public function connect(): void 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; } try { diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index a2dd3621..51bc44d1 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -22,6 +22,7 @@ function createTestDatabaseConfig( string $username = 'root', string $password = '', ?string $sslCa = null, + bool $sslVerifyServerCert = false, ): DatabaseConfig { $tempDir = sys_get_temp_dir() . '/marko_mysql_test_' . uniqid(); mkdir($tempDir . '/config', recursive: true); @@ -39,6 +40,10 @@ function createTestDatabaseConfig( $configArray['ssl_ca'] = $sslCa; } + if ($sslVerifyServerCert) { + $configArray['ssl_verify_server_cert'] = true; + } + file_put_contents( $tempDir . '/config/database.php', '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 false 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])->toBeFalse(); + }); + it('omits SSL CA cert from PDO options when not configured', function (): void { $capturedOptions = []; $config = createTestDatabaseConfig(); diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index b7effff8..b698fd95 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -28,6 +28,8 @@ public ?string $sslRootCert; + public bool $sslVerifyServerCert; + /** * @throws ConfigurationException */ @@ -58,5 +60,6 @@ public function __construct( $this->password = $config['password']; $this->sslMode = $config['sslmode'] ?? null; $this->sslRootCert = $config['ssl_ca'] ?? null; + $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? false; } } diff --git a/packages/database/tests/DatabaseConfigTest.php b/packages/database/tests/DatabaseConfigTest.php index 3b34fdb2..2441d881 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -167,7 +167,42 @@ $config = new DatabaseConfig($paths); expect($config->sslMode)->toBe('require') - ->and($config->sslRootCert)->toBe('/path/to/ca.pem'); + ->and($config->sslRootCert)->toBe('/path/to/ca.pem') + ->and($config->sslVerifyServerCert)->toBeFalse(); + } 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); @@ -199,7 +234,8 @@ $config = new DatabaseConfig($paths); expect($config->sslMode)->toBeNull() - ->and($config->sslRootCert)->toBeNull(); + ->and($config->sslRootCert)->toBeNull() + ->and($config->sslVerifyServerCert)->toBeFalse(); } finally { unlink($configDir . '/database.php'); rmdir($configDir); From 14cf418cec41b19746d3cf4ee6e2507ed8efb583 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 22:40:20 +0100 Subject: [PATCH 3/9] feat: db mututal tls handshake support --- .../src/Connection/MySqlConnection.php | 8 ++ .../tests/Connection/MySqlConnectionTest.php | 101 ++++++++++++++++++ .../src/Connection/PgSqlConnection.php | 8 ++ .../tests/Connection/PgSqlConnectionTest.php | 38 +++++++ .../database/src/Config/DatabaseConfig.php | 6 ++ .../database/tests/DatabaseConfigTest.php | 38 ++++++- 6 files changed, 198 insertions(+), 1 deletion(-) diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index 1a679d24..7f706486 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -52,6 +52,14 @@ public function connect(): void $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 51bc44d1..f7cdfb2a 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -23,6 +23,8 @@ function createTestDatabaseConfig( 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); @@ -44,6 +46,14 @@ function createTestDatabaseConfig( $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', 'toBeFalse(); }); + it('passes SSL client cert in PDO options when configured', function (): void { + $capturedOptions = []; + $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.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(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(); diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index 29da48d2..eaf60856 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -87,6 +87,14 @@ private function buildDsn(): string $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; } diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 8348aac5..1fdb0398 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -23,6 +23,8 @@ function createTestPgSqlConfig( 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); @@ -44,6 +46,14 @@ function createTestPgSqlConfig( $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', 'getDsn())->not->toContain('sslrootcert'); }); + it('includes sslcert in DSN when configured', function (): void { + $config = createTestPgSqlConfig(sslCert: '/path/to/client-cert.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(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 b698fd95..e34db449 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -30,6 +30,10 @@ public bool $sslVerifyServerCert; + public ?string $sslCert; + + public ?string $sslKey; + /** * @throws ConfigurationException */ @@ -61,5 +65,7 @@ public function __construct( $this->sslMode = $config['sslmode'] ?? null; $this->sslRootCert = $config['ssl_ca'] ?? null; $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? false; + $this->sslCert = $config['ssl_cert'] ?? null; + $this->sslKey = $config['ssl_key'] ?? null; } } diff --git a/packages/database/tests/DatabaseConfigTest.php b/packages/database/tests/DatabaseConfigTest.php index 2441d881..977e01c2 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -210,6 +210,40 @@ } }); + 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'; @@ -235,7 +269,9 @@ expect($config->sslMode)->toBeNull() ->and($config->sslRootCert)->toBeNull() - ->and($config->sslVerifyServerCert)->toBeFalse(); + ->and($config->sslVerifyServerCert)->toBeFalse() + ->and($config->sslCert)->toBeNull() + ->and($config->sslKey)->toBeNull(); } finally { unlink($configDir . '/database.php'); rmdir($configDir); From 9f6517bd4c106476f7935e7ed34df1d22b4cd5e1 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 23:00:31 +0100 Subject: [PATCH 4/9] feat: update to new PHP 8.5 PDO consts --- .../src/Connection/MySqlConnection.php | 8 ++++---- .../tests/Connection/MySqlConnectionTest.php | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/database-mysql/src/Connection/MySqlConnection.php b/packages/database-mysql/src/Connection/MySqlConnection.php index 7f706486..c3d219c0 100644 --- a/packages/database-mysql/src/Connection/MySqlConnection.php +++ b/packages/database-mysql/src/Connection/MySqlConnection.php @@ -48,16 +48,16 @@ public function connect(): void ]; 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; + $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; + $options[Pdo\Mysql::ATTR_SSL_CERT] = $this->config->sslCert; } if ($this->config->sslKey !== null) { - $options[PDO::MYSQL_ATTR_SSL_KEY] = $this->config->sslKey; + $options[Pdo\Mysql::ATTR_SSL_KEY] = $this->config->sslKey; } try { diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index f7cdfb2a..f1d42afd 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -590,7 +590,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[PDO::MYSQL_ATTR_SSL_CA])->toBe('/path/to/ca.pem'); + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_CA])->toBe('/path/to/ca.pem'); }); it('sets SSL verify server cert when configured', function (): void { @@ -620,8 +620,8 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[PDO::MYSQL_ATTR_SSL_CA])->toBe('/path/to/ca.pem') - ->and($capturedOptions[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT])->toBeTrue(); + 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 false when ssl_ca is set', function (): void { @@ -651,7 +651,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT])->toBeFalse(); + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeFalse(); }); it('passes SSL client cert in PDO options when configured', function (): void { @@ -681,7 +681,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[PDO::MYSQL_ATTR_SSL_CERT])->toBe('/path/to/client-cert.pem'); + 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 { @@ -711,7 +711,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[PDO::MYSQL_ATTR_SSL_KEY])->toBe('/path/to/client-key.pem'); + 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 { @@ -741,8 +741,8 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions)->not->toHaveKey(PDO::MYSQL_ATTR_SSL_CERT) - ->and($capturedOptions)->not->toHaveKey(PDO::MYSQL_ATTR_SSL_KEY); + 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 { @@ -772,7 +772,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions)->not->toHaveKey(PDO::MYSQL_ATTR_SSL_CA); + expect($capturedOptions)->not->toHaveKey(Pdo\Mysql::ATTR_SSL_CA); }); it('prevents nested transactions (throws exception)', function (): void { From 31a6dd075cc3c23ce9cd737271ca6eaa9dc8d284 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 23:14:51 +0100 Subject: [PATCH 5/9] feat: validate ssl keypair both present --- .../database/src/Config/DatabaseConfig.php | 8 +++ .../src/Exceptions/ConfigurationException.php | 11 ++++ .../database/tests/DatabaseConfigTest.php | 66 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index e34db449..e87cf796 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -67,5 +67,13 @@ public function __construct( $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? false; $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 977e01c2..1c8ac250 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -279,6 +279,72 @@ } }); + 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'; From be95f3e5dc1625825349e64d1e293d4036a7740c Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Wed, 1 Apr 2026 23:51:40 +0100 Subject: [PATCH 6/9] feat: fix tests now that key pair is validated together --- .../database-mysql/tests/Connection/MySqlConnectionTest.php | 4 ++-- .../database-pgsql/tests/Connection/PgSqlConnectionTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index f1d42afd..03c80913 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -656,7 +656,7 @@ protected function createPdo( it('passes SSL client cert in PDO options when configured', function (): void { $capturedOptions = []; - $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.pem'); + $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); $connection = new class ($config, $capturedOptions) extends MySqlConnection { @@ -686,7 +686,7 @@ protected function createPdo( it('passes SSL client key in PDO options when configured', function (): void { $capturedOptions = []; - $config = createTestDatabaseConfig(sslKey: '/path/to/client-key.pem'); + $config = createTestDatabaseConfig(sslCert: '/path/to/client-cert.pem', sslKey: '/path/to/client-key.pem'); $connection = new class ($config, $capturedOptions) extends MySqlConnection { diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 1fdb0398..0a144520 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -639,7 +639,7 @@ protected function createPdo( }); it('includes sslcert in DSN when configured', function (): void { - $config = createTestPgSqlConfig(sslCert: '/path/to/client-cert.pem'); + $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'); @@ -653,7 +653,7 @@ protected function createPdo( }); it('includes sslkey in DSN when configured', function (): void { - $config = createTestPgSqlConfig(sslKey: '/path/to/client-key.pem'); + $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'); From d5ec4cd959f38ad064c3ffe22826885f9251fd5d Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Thu, 2 Apr 2026 00:00:15 +0100 Subject: [PATCH 7/9] chore(phpcs): move params on to new lines while i'm in here --- packages/database-pgsql/src/Connection/PgSqlConnection.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index eaf60856..0efdf44b 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -181,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) { From 0a373e7e5a12c7a44cf4999664f1f429e15e9918 Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Thu, 2 Apr 2026 00:32:33 +0100 Subject: [PATCH 8/9] feat: default mysql cert validation to true if a cert is provided --- packages/database/src/Config/DatabaseConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/Config/DatabaseConfig.php b/packages/database/src/Config/DatabaseConfig.php index e87cf796..eccda9bb 100644 --- a/packages/database/src/Config/DatabaseConfig.php +++ b/packages/database/src/Config/DatabaseConfig.php @@ -64,7 +64,7 @@ public function __construct( $this->password = $config['password']; $this->sslMode = $config['sslmode'] ?? null; $this->sslRootCert = $config['ssl_ca'] ?? null; - $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? false; + $this->sslVerifyServerCert = $config['ssl_verify_server_cert'] ?? ($this->sslRootCert !== null); $this->sslCert = $config['ssl_cert'] ?? null; $this->sslKey = $config['ssl_key'] ?? null; From af9dc98c47c1ba48e977fa1e4e0273cb99dd870f Mon Sep 17 00:00:00 2001 From: Sam Steele Date: Thu, 2 Apr 2026 00:35:15 +0100 Subject: [PATCH 9/9] feat: fix tests after the previous change --- .../database-mysql/tests/Connection/MySqlConnectionTest.php | 4 ++-- packages/database/tests/DatabaseConfigTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php index 03c80913..939287e7 100644 --- a/packages/database-mysql/tests/Connection/MySqlConnectionTest.php +++ b/packages/database-mysql/tests/Connection/MySqlConnectionTest.php @@ -624,7 +624,7 @@ protected function createPdo( ->and($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeTrue(); }); - it('defaults SSL verify server cert to false when ssl_ca is set', function (): void { + it('defaults SSL verify server cert to true when ssl_ca is set', function (): void { $capturedOptions = []; $config = createTestDatabaseConfig(sslCa: '/path/to/ca.pem'); @@ -651,7 +651,7 @@ protected function createPdo( $connection->connect(); - expect($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeFalse(); + expect($capturedOptions[Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT])->toBeTrue(); }); it('passes SSL client cert in PDO options when configured', function (): void { diff --git a/packages/database/tests/DatabaseConfigTest.php b/packages/database/tests/DatabaseConfigTest.php index 1c8ac250..7c6821c3 100644 --- a/packages/database/tests/DatabaseConfigTest.php +++ b/packages/database/tests/DatabaseConfigTest.php @@ -168,7 +168,7 @@ expect($config->sslMode)->toBe('require') ->and($config->sslRootCert)->toBe('/path/to/ca.pem') - ->and($config->sslVerifyServerCert)->toBeFalse(); + ->and($config->sslVerifyServerCert)->toBeTrue(); } finally { unlink($configDir . '/database.php'); rmdir($configDir);