From f1dadbda3128994b1d29cd48760fe3be4281ddd5 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 16:40:39 +0100 Subject: [PATCH 1/9] feat: improved tenant isolation --- .../content/core/config/database.php.original | 1 + phpmyfaq/src/phpMyFAQ/Bootstrapper.php | 26 ++++++ .../Configuration/DatabaseConfiguration.php | 9 ++ .../MultisiteConfigurationLocator.php | 42 +++++++++ .../phpMyFAQ/Enums/TenantIsolationMode.php | 34 ++++++++ phpmyfaq/src/phpMyFAQ/Instance/Client.php | 85 ++++++++++++++++++- .../Instance/Database/DriverInterface.php | 5 +- .../src/phpMyFAQ/Instance/Database/Mysqli.php | 7 +- .../phpMyFAQ/Instance/Database/PdoMysql.php | 7 +- .../phpMyFAQ/Instance/Database/PdoPgsql.php | 7 +- .../phpMyFAQ/Instance/Database/PdoSqlite.php | 4 +- .../phpMyFAQ/Instance/Database/PdoSqlsrv.php | 12 ++- .../src/phpMyFAQ/Instance/Database/Pgsql.php | 7 +- .../phpMyFAQ/Instance/Database/Sqlite3.php | 4 +- .../src/phpMyFAQ/Instance/Database/Sqlsrv.php | 12 ++- phpmyfaq/src/phpMyFAQ/Instance/Setup.php | 3 + .../Setup/Installation/SchemaInstaller.php | 46 +++++++++- tests/phpMyFAQ/BootstrapperTest.php | 8 ++ .../DatabaseConfigurationTest.php | 1 + .../MultisiteConfigurationLocatorTest.php | 70 +++++++++++++++ .../Enums/TenantIsolationModeTest.php | 29 +++++++ tests/phpMyFAQ/Instance/ClientTest.php | 51 +++++++++++ 22 files changed, 458 insertions(+), 12 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Enums/TenantIsolationMode.php create mode 100644 tests/phpMyFAQ/Enums/TenantIsolationModeTest.php diff --git a/phpmyfaq/content/core/config/database.php.original b/phpmyfaq/content/core/config/database.php.original index 4f3c39b84d..350d5371af 100755 --- a/phpmyfaq/content/core/config/database.php.original +++ b/phpmyfaq/content/core/config/database.php.original @@ -6,3 +6,4 @@ $DB['password'] = ''; $DB['db'] = ''; $DB['prefix'] = ''; $DB['type'] = ''; +$DB['schema'] = ''; diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index 6142cb2c0d..0f406bbd25 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -146,6 +146,8 @@ private function connectDatabase(string $databaseFile): void $dbConfig->getDatabase(), $dbConfig->getPort(), ); + + $this->switchToTenantSchema($dbConfig); } catch (Exception $exception) { throw new DatabaseConnectionException( message: 'Database connection failed: ' . $exception->getMessage(), @@ -166,6 +168,30 @@ private function connectDatabase(string $databaseFile): void } } + /** + * Switches to the tenant's schema or database after connection, if configured. + */ + private function switchToTenantSchema(DatabaseConfiguration $dbConfig): void + { + $schema = $dbConfig->getSchema(); + if ($schema === null || $schema === '') { + return; + } + + $dbType = $dbConfig->getType(); + + if (str_contains($dbType, 'mysql') || str_contains($dbType, 'mysqli')) { + $this->db->query(sprintf('USE `%s`', $schema)); + return; + } + + if (str_contains($dbType, 'pgsql')) { + $this->db->query(sprintf('SET search_path TO "%s"', $schema)); + } + + // SQL Server uses schema prefix in queries; no global switch needed. + } + private function configureLdap(): void { if ($this->faqConfig->isLdapActive() && file_exists(PMF_CONFIG_DIR . '/ldap.php') && extension_loaded('ldap')) { diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/DatabaseConfiguration.php b/phpmyfaq/src/phpMyFAQ/Configuration/DatabaseConfiguration.php index 2bd97809ce..bf3a74fa67 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration/DatabaseConfiguration.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration/DatabaseConfiguration.php @@ -35,6 +35,8 @@ private string $type; + private ?string $schema; + public function __construct(string $filename) { $DB = [ @@ -45,6 +47,7 @@ public function __construct(string $filename) 'db' => '', 'prefix' => '', 'type' => '', + 'schema' => '', ]; include $filename; @@ -56,6 +59,7 @@ public function __construct(string $filename) $this->database = $DB['db']; $this->prefix = $DB['prefix']; $this->type = $DB['type']; + $this->schema = $DB['schema'] === '' ? null : $DB['schema']; } public function getServer(): string @@ -92,4 +96,9 @@ public function getType(): string { return $this->type; } + + public function getSchema(): ?string + { + return $this->schema; + } } diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php index 7b5c6c8a1f..664df53b36 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php @@ -32,13 +32,55 @@ public static function locateConfigurationDirectory(Request $request, string $co $parsed = parse_url($protocol . '://' . $host . $scriptName); if (isset($parsed['host']) && $parsed['host'] !== '') { + // 1. Try exact hostname match (existing behavior) $configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $parsed['host']; if (is_dir($configDir)) { return $configDir; } + + // 2. Try subdomain-based tenant matching + $tenantName = self::extractTenantFromSubdomain($parsed['host']); + if ($tenantName !== null) { + $configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $tenantName; + + if (is_dir($configDir)) { + return $configDir; + } + } } return null; } + + /** + * Extracts the tenant identifier from a subdomain pattern. + * + * Checks the PMF_MULTISITE_BASE_DOMAIN environment variable. If set, + * extracts the subdomain part from hostnames matching {tenant}.{baseDomain}. + * + * Example: With PMF_MULTISITE_BASE_DOMAIN=faq.example.com, + * the host "acme.faq.example.com" returns "acme". + */ + public static function extractTenantFromSubdomain(string $host): ?string + { + $baseDomain = getenv('PMF_MULTISITE_BASE_DOMAIN'); + if ($baseDomain === false || $baseDomain === '') { + return null; + } + + $baseDomain = ltrim($baseDomain, characters: '.'); + $suffix = '.' . $baseDomain; + + if (!str_ends_with($host, $suffix)) { + return null; + } + + $tenant = substr($host, offset: 0, length: -strlen($suffix)); + if ($tenant === '' || str_contains($tenant, '.')) { + return null; + } + + return $tenant; + } } diff --git a/phpmyfaq/src/phpMyFAQ/Enums/TenantIsolationMode.php b/phpmyfaq/src/phpMyFAQ/Enums/TenantIsolationMode.php new file mode 100644 index 0000000000..037b33de64 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Enums/TenantIsolationMode.php @@ -0,0 +1,34 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Enums; + +enum TenantIsolationMode: string +{ + case PREFIX = 'prefix'; + case SCHEMA = 'schema'; + case DATABASE = 'database'; +} diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 4bdccb4e80..0d90880a71 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -22,6 +22,7 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; use phpMyFAQ\Database; +use phpMyFAQ\Enums\TenantIsolationMode; use phpMyFAQ\Filesystem\Filesystem; use phpMyFAQ\Instance; use phpMyFAQ\Instance\Database as InstanceDatabase; @@ -84,6 +85,88 @@ public function createClientFolder(string $hostname): bool return $this->filesystem->createDirectory($this->clientFolder . $hostname); } + /** + * Creates client database isolation based on the configured tenant isolation mode. + * + * Supports three isolation strategies: + * - prefix: Table prefix isolation in a shared database (default, delegates to createClientTables) + * - schema: Schema-per-tenant in a shared database + * - database: Separate database per tenant + * + * @param string $tenantIdentifier The prefix, schema name, or database name for the tenant + * @param TenantIsolationMode|null $mode Isolation mode defaults to reading from PMF_TENANT_ISOLATION_MODE env var + */ + public function createClientDatabase(string $tenantIdentifier, ?TenantIsolationMode $mode = null): void + { + $envValue = getenv('PMF_TENANT_ISOLATION_MODE'); + $mode ??= + TenantIsolationMode::tryFrom($envValue !== false && $envValue !== '' ? $envValue : 'prefix') + ?? TenantIsolationMode::PREFIX; + + match ($mode) { + TenantIsolationMode::PREFIX => $this->createClientTables($tenantIdentifier), + TenantIsolationMode::SCHEMA, TenantIsolationMode::DATABASE => $this->createClientTablesWithSchema( + $tenantIdentifier, + ), + }; + } + + /** + * Creates all tables in a dedicated schema or database for tenant isolation. + * + * @param string $schema Schema or database name for the tenant + */ + private function createClientTablesWithSchema(string $schema): void + { + try { + $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType()); + $instanceDatabase->createTables('', $schema); + + $this->copyBaseDataToSchema($schema); + } catch (Exception) { + } + } + + /** + * Copies base configuration, rights, and user data into a tenant's schema/database. + */ + private function copyBaseDataToSchema(string $schema): void + { + $dbType = Database::getType(); + $sourcePrefix = Database::getTablePrefix(); + + $targetPrefix = sprintf('`%s`.', $schema); + + if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql')) { + $targetPrefix = sprintf('"%s".', $schema); + $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + } + + $this->configuration + ->getDb() + ->query(sprintf('INSERT INTO %sfaqconfig SELECT * FROM %sfaqconfig', $targetPrefix, $sourcePrefix)); + + $this->configuration + ->getDb() + ->query(sprintf( + "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'", + $targetPrefix, + $this->clientUrl, + )); + + $this->configuration + ->getDb() + ->query(sprintf('INSERT INTO %sfaqright SELECT * FROM %sfaqright', $targetPrefix, $sourcePrefix)); + + $this->configuration + ->getDb() + ->query(sprintf( + 'INSERT INTO %sfaquser_right SELECT * FROM %sfaquser_right WHERE user_id = 1', + $targetPrefix, + $sourcePrefix, + )); + } + /** * Creates all tables with the given table prefix from the primary tables. * @@ -144,7 +227,7 @@ public function copyConstantsFile(string $destination): bool } /** - * Copies a defined template folder to a new client instance, by default, + * Copies a defined template folder to a new client instance; by default, * the default template located at ./assets/templates/default/ will be copied. * * @param string $destination Destination folder diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/DriverInterface.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/DriverInterface.php index e5783ec32b..2a7bfeeaea 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/DriverInterface.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/DriverInterface.php @@ -29,7 +29,8 @@ interface DriverInterface /** * Executes all CREATE TABLE and CREATE INDEX statements. * - * + * @param string $prefix Table prefix for prefix-based isolation + * @param string|null $schema Schema or database name for schema/database-based isolation */ - public function createTables(string $prefix = ''): bool; + public function createTables(string $prefix = '', ?string $schema = null): bool; } diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php index f0344f119d..16837811b6 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php @@ -398,8 +398,13 @@ public function __construct(Configuration $configuration) * * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema)); + $this->configuration->getDb()->query(sprintf('USE `%s`', $schema)); + } + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php index eb91001268..4b02e41c0c 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php @@ -397,8 +397,13 @@ public function __construct(Configuration $configuration) * * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema)); + $this->configuration->getDb()->query(sprintf('USE `%s`', $schema)); + } + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php index 9d726293ea..515a7b0589 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php @@ -387,8 +387,13 @@ public function __construct(Configuration $configuration) * Executes all CREATE TABLE and CREATE INDEX statements. * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema)); + $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + } + foreach ($this->createTableStatements as $key => $stmt) { if ($key == 'idx_records' || $key == 'faqsessions_idx') { $result = $this->configuration->getDb()->query(sprintf($stmt, $prefix, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php index 1b2a507f95..d2a710ba1b 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php @@ -385,8 +385,10 @@ public function __construct(Configuration $configuration) * * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + // SQLite does not support schema-based isolation; use separate database files instead. + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php index 402ec267a6..a4ab9dae7c 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php @@ -384,8 +384,18 @@ public function __construct(Configuration $configuration) /** * Executes all CREATE TABLE and CREATE INDEX statements. */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration + ->getDb() + ->query(sprintf( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')", + $schema, + $schema, + )); + } + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php index 05167b50b5..aa411f1672 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php @@ -388,8 +388,13 @@ public function __construct(Configuration $configuration) * Executes all CREATE TABLE and CREATE INDEX statements. * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema)); + $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + } + foreach ($this->createTableStatements as $key => $stmt) { if ( $key == 'idx_records' diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php index 200612a655..b90e82d13e 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php @@ -386,8 +386,10 @@ public function __construct(Configuration $configuration) * * */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + // SQLite does not support schema-based isolation; use separate database files instead. + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php index d82060e6c7..2fc2a0692e 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php @@ -385,8 +385,18 @@ public function __construct(Configuration $configuration) /** * Executes all CREATE TABLE and CREATE INDEX statements. */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { + if ($schema !== null && $schema !== '') { + $this->configuration + ->getDb() + ->query(sprintf( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')", + $schema, + $schema, + )); + } + foreach ($this->createTableStatements as $createTableStatement) { $result = $this->configuration->getDb()->query(sprintf($createTableStatement, $prefix)); diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php index 1f7100ccb7..c02ad6227b 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php @@ -140,6 +140,9 @@ public function createDatabaseFile(array $data, string $folder = '/content/core/ . "';\n" . "\$DB['type'] = '" . $data['dbType'] + . "';\n" + . "\$DB['schema'] = '" + . ($data['dbSchema'] ?? '') . "';", LOCK_EX, ); diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php index 00d9c80de7..b58cb46107 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/SchemaInstaller.php @@ -61,8 +61,11 @@ public function getSchema(): DatabaseSchema * Executes all CREATE TABLE and CREATE INDEX statements. * * @param string $prefix Table prefix to apply. The previous prefix is restored after execution. + * @param string|null $schema Schema or database name for schema/database-based tenant isolation. + * For MySQL: creates and switches to a database. + * For PostgreSQL: creates and switches to a schema. */ - public function createTables(string $prefix = ''): bool + public function createTables(string $prefix = '', ?string $schema = null): bool { $previousPrefix = Database::getTablePrefix(); @@ -73,6 +76,12 @@ public function createTables(string $prefix = ''): bool $this->collectedSql = []; try { + if ($schema !== null && $schema !== '') { + if (!$this->createAndUseSchema($schema)) { + return false; + } + } + foreach ($this->schema->getAllTables() as $tableBuilder) { $createTableSql = $tableBuilder->build(); @@ -97,6 +106,41 @@ public function createTables(string $prefix = ''): bool } } + /** + * Creates a schema/database and switches to it. + * + * For MySQL: CREATE DATABASE + USE. + * For PostgreSQL: CREATE SCHEMA + SET search_path. + */ + private function createAndUseSchema(string $schema): bool + { + $dialectClass = $this->dialect::class; + + if (str_contains($dialectClass, 'Mysql')) { + return ( + $this->executeSql(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema)) + && $this->executeSql(sprintf('USE `%s`', $schema)) + ); + } + + if (str_contains($dialectClass, 'Pgsql')) { + return ( + $this->executeSql(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema)) + && $this->executeSql(sprintf('SET search_path TO "%s"', $schema)) + ); + } + + if (str_contains($dialectClass, 'Sqlsrv')) { + return $this->executeSql(sprintf( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')", + $schema, + $schema, + )); + } + + return true; + } + /** * Executes all DROP TABLE statements for the schema tables. */ diff --git a/tests/phpMyFAQ/BootstrapperTest.php b/tests/phpMyFAQ/BootstrapperTest.php index 8c87a562f4..16df96b620 100644 --- a/tests/phpMyFAQ/BootstrapperTest.php +++ b/tests/phpMyFAQ/BootstrapperTest.php @@ -17,6 +17,7 @@ namespace phpMyFAQ; +use phpMyFAQ\Configuration\DatabaseConfiguration; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -31,4 +32,11 @@ public function testGettersReturnNullBeforeRun(): void $this->assertNull($bootstrapper->getDb()); $this->assertNull($bootstrapper->getRequest()); } + + public function testDatabaseConfigurationIncludesSchemaField(): void + { + $config = new DatabaseConfiguration(dirname(__FILE__, 2) . '/content/core/config/database.php'); + + $this->assertNull($config->getSchema()); + } } diff --git a/tests/phpMyFAQ/Configuration/DatabaseConfigurationTest.php b/tests/phpMyFAQ/Configuration/DatabaseConfigurationTest.php index 13b1d40464..63b3932dac 100644 --- a/tests/phpMyFAQ/Configuration/DatabaseConfigurationTest.php +++ b/tests/phpMyFAQ/Configuration/DatabaseConfigurationTest.php @@ -19,5 +19,6 @@ public function testDBConfigProperties(): void $this->assertEquals('', $config->getDatabase()); $this->assertEquals('', $config->getPrefix()); $this->assertEquals('pdo_sqlite', $config->getType()); + $this->assertNull($config->getSchema()); } } diff --git a/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php b/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php index 21c3e74ff1..1f71c0d880 100644 --- a/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php +++ b/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php @@ -61,4 +61,74 @@ public function testLocateConfigurationDirectoryReturnsNullIfHostIsEmpty(): void $result = MultisiteConfigurationLocator::locateConfigurationDirectory($request, $baseConfigurationDirectory); $this->assertNull($result); } + + /** + * @throws Exception + */ + public function testLocateConfigurationDirectoryWithSubdomainPattern(): void + { + $tenantDir = __DIR__ . '/acme'; + if (!is_dir($tenantDir)) { + mkdir($tenantDir); + } + + putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); + + $request = $this->createStub(Request::class); + $request->method('isSecure')->willReturn(true); + $request->method('getHost')->willReturn('acme.faq.example.com'); + $request->method('getScriptName')->willReturn('/index.php'); + + $result = MultisiteConfigurationLocator::locateConfigurationDirectory($request, __DIR__); + $this->assertEquals($tenantDir, $result); + + rmdir($tenantDir); + putenv('PMF_MULTISITE_BASE_DOMAIN'); + } + + public function testExtractTenantFromSubdomainWithValidHost(): void + { + putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); + + $this->assertEquals('acme', MultisiteConfigurationLocator::extractTenantFromSubdomain('acme.faq.example.com')); + $this->assertEquals('test', MultisiteConfigurationLocator::extractTenantFromSubdomain('test.faq.example.com')); + + putenv('PMF_MULTISITE_BASE_DOMAIN'); + } + + public function testExtractTenantFromSubdomainReturnsNullWithoutBaseDomain(): void + { + putenv('PMF_MULTISITE_BASE_DOMAIN'); + + $this->assertNull(MultisiteConfigurationLocator::extractTenantFromSubdomain('acme.faq.example.com')); + } + + public function testExtractTenantFromSubdomainReturnsNullForNonMatchingHost(): void + { + putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); + + $this->assertNull(MultisiteConfigurationLocator::extractTenantFromSubdomain('other.domain.com')); + + putenv('PMF_MULTISITE_BASE_DOMAIN'); + } + + public function testExtractTenantFromSubdomainRejectsNestedSubdomains(): void + { + putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); + + $this->assertNull( + MultisiteConfigurationLocator::extractTenantFromSubdomain('deep.nested.faq.example.com') + ); + + putenv('PMF_MULTISITE_BASE_DOMAIN'); + } + + public function testExtractTenantFromSubdomainRejectsEmptyTenant(): void + { + putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); + + $this->assertNull(MultisiteConfigurationLocator::extractTenantFromSubdomain('faq.example.com')); + + putenv('PMF_MULTISITE_BASE_DOMAIN'); + } } diff --git a/tests/phpMyFAQ/Enums/TenantIsolationModeTest.php b/tests/phpMyFAQ/Enums/TenantIsolationModeTest.php new file mode 100644 index 0000000000..51c69af9e6 --- /dev/null +++ b/tests/phpMyFAQ/Enums/TenantIsolationModeTest.php @@ -0,0 +1,29 @@ +assertEquals('prefix', TenantIsolationMode::PREFIX->value); + $this->assertEquals('schema', TenantIsolationMode::SCHEMA->value); + $this->assertEquals('database', TenantIsolationMode::DATABASE->value); + } + + public function testTryFromValidValues(): void + { + $this->assertEquals(TenantIsolationMode::PREFIX, TenantIsolationMode::tryFrom('prefix')); + $this->assertEquals(TenantIsolationMode::SCHEMA, TenantIsolationMode::tryFrom('schema')); + $this->assertEquals(TenantIsolationMode::DATABASE, TenantIsolationMode::tryFrom('database')); + } + + public function testTryFromInvalidValueReturnsNull(): void + { + $this->assertNull(TenantIsolationMode::tryFrom('invalid')); + } +} diff --git a/tests/phpMyFAQ/Instance/ClientTest.php b/tests/phpMyFAQ/Instance/ClientTest.php index ce3f1dbbfa..68c8c3a62a 100644 --- a/tests/phpMyFAQ/Instance/ClientTest.php +++ b/tests/phpMyFAQ/Instance/ClientTest.php @@ -4,6 +4,7 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Database\DatabaseDriver; +use phpMyFAQ\Enums\TenantIsolationMode; use phpMyFAQ\Filesystem\Filesystem; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; @@ -93,4 +94,54 @@ public function testDeleteClientFolder(): void $this->assertTrue($result); } + + public function testCreateClientDatabaseWithPrefixMode(): void + { + $prefix = 'tenant_'; + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->expects($this->atLeastOnce())->method('query'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase($prefix, TenantIsolationMode::PREFIX); + } + + public function testCreateClientDatabaseWithSchemaMode(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->expects($this->atLeastOnce())->method('query'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('tenant_schema', TenantIsolationMode::SCHEMA); + } + + public function testCreateClientDatabaseWithDatabaseMode(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->expects($this->atLeastOnce())->method('query'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('tenant_db', TenantIsolationMode::DATABASE); + } + + public function testCreateClientDatabaseDefaultsToPrefix(): void + { + putenv('PMF_TENANT_ISOLATION_MODE=prefix'); + + $prefix = 'default_'; + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->expects($this->atLeastOnce())->method('query'); + + $this->client->setClientUrl('https://default.example.com'); + $this->client->createClientDatabase($prefix); + + putenv('PMF_TENANT_ISOLATION_MODE'); + } } From f996618b0dd1384b161b85cba31242740824930b Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 18:47:46 +0100 Subject: [PATCH 2/9] fix: corrected tests --- .../Bootstrap/ConfigDirectoryResolverTest.php | 15 ---------- .../Bootstrap/PhpConfiguratorTest.php | 28 ------------------- .../Bootstrap/SearchClientFactoryTest.php | 15 ---------- tests/phpMyFAQ/BootstrapperTest.php | 17 ----------- 4 files changed, 75 deletions(-) diff --git a/tests/phpMyFAQ/Bootstrap/ConfigDirectoryResolverTest.php b/tests/phpMyFAQ/Bootstrap/ConfigDirectoryResolverTest.php index 8ff2988cb7..c87987dfca 100644 --- a/tests/phpMyFAQ/Bootstrap/ConfigDirectoryResolverTest.php +++ b/tests/phpMyFAQ/Bootstrap/ConfigDirectoryResolverTest.php @@ -1,20 +1,5 @@ - * @copyright 2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2026-02-08 - */ - namespace phpMyFAQ\Bootstrap; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php b/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php index 1e9b12082e..a08336de8e 100644 --- a/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php +++ b/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php @@ -1,20 +1,5 @@ - * @copyright 2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2026-02-08 - */ - namespace phpMyFAQ\Bootstrap; use PHPUnit\Framework\Attributes\CoversClass; @@ -38,17 +23,4 @@ public function testConfigurePcreSetsLimits(): void $this->assertEquals('100000000', ini_get('pcre.backtrack_limit')); $this->assertEquals('100000000', ini_get('pcre.recursion_limit')); } - - public function testConfigureSessionSetsIniValues(): void - { - if (session_status() === PHP_SESSION_ACTIVE) { - $this->markTestSkipped('Session already active; ini values cannot be changed.'); - } - - PhpConfigurator::configureSession(); - - $this->assertEquals('1', ini_get('session.use_only_cookies')); - $this->assertEquals('0', ini_get('session.use_trans_sid')); - $this->assertEquals('1', ini_get('session.cookie_httponly')); - } } diff --git a/tests/phpMyFAQ/Bootstrap/SearchClientFactoryTest.php b/tests/phpMyFAQ/Bootstrap/SearchClientFactoryTest.php index ff5dd38682..52165f9ab3 100644 --- a/tests/phpMyFAQ/Bootstrap/SearchClientFactoryTest.php +++ b/tests/phpMyFAQ/Bootstrap/SearchClientFactoryTest.php @@ -1,20 +1,5 @@ - * @copyright 2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2026-02-08 - */ - namespace phpMyFAQ\Bootstrap; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/phpMyFAQ/BootstrapperTest.php b/tests/phpMyFAQ/BootstrapperTest.php index 16df96b620..044c3dd4de 100644 --- a/tests/phpMyFAQ/BootstrapperTest.php +++ b/tests/phpMyFAQ/BootstrapperTest.php @@ -1,27 +1,10 @@ - * @copyright 2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2026-02-08 - */ - namespace phpMyFAQ; use phpMyFAQ\Configuration\DatabaseConfiguration; -use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -#[CoversClass(Bootstrapper::class)] class BootstrapperTest extends TestCase { public function testGettersReturnNullBeforeRun(): void From d99d18c380dc5b3d5dd2881dd027ecfa5efb6516 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 19:05:26 +0100 Subject: [PATCH 3/9] feat: added tenant context --- .../src/phpMyFAQ/Tenant/TenantContext.php | 63 ++++++++++++++ .../phpMyFAQ/Tenant/TenantContextResolver.php | 76 ++++++++++++++++ phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotas.php | 57 ++++++++++++ phpmyfaq/src/services.php | 8 ++ .../Tenant/TenantContextResolverTest.php | 86 +++++++++++++++++++ tests/phpMyFAQ/Tenant/TenantContextTest.php | 33 +++++++ tests/phpMyFAQ/Tenant/TenantQuotasTest.php | 32 +++++++ 7 files changed, 355 insertions(+) create mode 100644 phpmyfaq/src/phpMyFAQ/Tenant/TenantContext.php create mode 100644 phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php create mode 100644 phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotas.php create mode 100644 tests/phpMyFAQ/Tenant/TenantContextResolverTest.php create mode 100644 tests/phpMyFAQ/Tenant/TenantContextTest.php create mode 100644 tests/phpMyFAQ/Tenant/TenantQuotasTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantContext.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContext.php new file mode 100644 index 0000000000..03c6f45084 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContext.php @@ -0,0 +1,63 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Tenant; + +readonly class TenantContext +{ + public function __construct( + private int $tenantId, + private string $hostname, + private string $tablePrefix, + private string $configDir, + private string $plan, + private TenantQuotas $quotas, + ) { + } + + public function getTenantId(): int + { + return $this->tenantId; + } + + public function getHostname(): string + { + return $this->hostname; + } + + public function getTablePrefix(): string + { + return $this->tablePrefix; + } + + public function getConfigDir(): string + { + return $this->configDir; + } + + public function getPlan(): string + { + return $this->plan; + } + + public function getQuotas(): TenantQuotas + { + return $this->quotas; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php new file mode 100644 index 0000000000..c216e5245e --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php @@ -0,0 +1,76 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Tenant; + +use phpMyFAQ\Database; +use Symfony\Component\HttpFoundation\Request; + +class TenantContextResolver +{ + public function resolve(?Request $request = null): TenantContext + { + $request ??= Request::createFromGlobals(); + $hostname = $request->getHost(); + + if ($hostname === '') { + $hostname = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); + } + + $configDir = defined('PMF_CONFIG_DIR') ? PMF_CONFIG_DIR : ''; + $tablePrefix = Database::getTablePrefix() ?? ''; + + $tenantId = $this->readIntEnv('PMF_TENANT_ID') ?? 0; + $plan = $this->readStringEnv('PMF_TENANT_PLAN') ?? 'free'; + + $quotas = new TenantQuotas( + $this->readIntEnv('PMF_TENANT_QUOTA_MAX_FAQS'), + $this->readIntEnv('PMF_TENANT_QUOTA_MAX_ATTACHMENT_SIZE'), + $this->readIntEnv('PMF_TENANT_QUOTA_MAX_USERS'), + $this->readIntEnv('PMF_TENANT_QUOTA_MAX_API_REQUESTS'), + $this->readIntEnv('PMF_TENANT_QUOTA_MAX_CATEGORIES'), + ); + + return new TenantContext($tenantId, $hostname, $tablePrefix, $configDir, $plan, $quotas); + } + + private function readIntEnv(string $key): ?int + { + $value = getenv($key); + if ($value === false || $value === '') { + return null; + } + + if (!is_numeric($value)) { + return null; + } + + return (int) $value; + } + + private function readStringEnv(string $key): ?string + { + $value = getenv($key); + if ($value === false || $value === '') { + return null; + } + + return trim((string) $value); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotas.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotas.php new file mode 100644 index 0000000000..de86aaf845 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotas.php @@ -0,0 +1,57 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Tenant; + +readonly class TenantQuotas +{ + public function __construct( + private ?int $maxFaqs = null, + private ?int $maxAttachmentSize = null, + private ?int $maxUsers = null, + private ?int $maxApiRequests = null, + private ?int $maxCategories = null, + ) { + } + + public function getMaxFaqs(): ?int + { + return $this->maxFaqs; + } + + public function getMaxAttachmentSize(): ?int + { + return $this->maxAttachmentSize; + } + + public function getMaxUsers(): ?int + { + return $this->maxUsers; + } + + public function getMaxApiRequests(): ?int + { + return $this->maxApiRequests; + } + + public function getMaxCategories(): ?int + { + return $this->maxCategories; + } +} diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index f8909051bf..041d042ac4 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -82,6 +82,8 @@ use phpMyFAQ\StopWords; use phpMyFAQ\System; use phpMyFAQ\Tags; +use phpMyFAQ\Tenant\TenantContext; +use phpMyFAQ\Tenant\TenantContextResolver; use phpMyFAQ\Translation\ContentTranslationService; use phpMyFAQ\Translation\TranslationProviderFactory; use phpMyFAQ\Translation\TranslationProviderInterface; @@ -216,6 +218,12 @@ service('phpmyfaq.comment.comments-repository'), ]); + $services->set('phpmyfaq.tenant.context-resolver', TenantContextResolver::class); + $services->set('phpmyfaq.tenant.context', TenantContext::class)->factory([ + service('phpmyfaq.tenant.context-resolver'), + 'resolve', + ]); + $services->set('phpmyfaq.configuration', Configuration::class)->factory([ Configuration::class, 'getConfigurationInstance', diff --git a/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php b/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php new file mode 100644 index 0000000000..b07483c946 --- /dev/null +++ b/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php @@ -0,0 +1,86 @@ +resolve(Request::create('https://acme.faq.example.com/dashboard')); + + $this->assertSame(17, $context->getTenantId()); + $this->assertSame('acme.faq.example.com', $context->getHostname()); + $this->assertSame('acme_', $context->getTablePrefix()); + $this->assertSame(PMF_CONFIG_DIR, $context->getConfigDir()); + $this->assertSame('enterprise', $context->getPlan()); + $this->assertSame(500, $context->getQuotas()->getMaxFaqs()); + $this->assertSame(1024, $context->getQuotas()->getMaxAttachmentSize()); + $this->assertSame(80, $context->getQuotas()->getMaxUsers()); + $this->assertSame(12000, $context->getQuotas()->getMaxApiRequests()); + $this->assertSame(200, $context->getQuotas()->getMaxCategories()); + } + + /** + * @throws Exception + */ + public function testResolveFallsBackToDefaultsWhenEnvironmentIsMissing(): void + { + $_SERVER['HTTP_HOST'] = 'fallback.example.com'; + Database::setTablePrefix(''); + + $request = $this->createStub(Request::class); + $request->method('getHost')->willReturn(''); + + $resolver = new TenantContextResolver(); + $context = $resolver->resolve($request); + + $this->assertSame(0, $context->getTenantId()); + $this->assertSame('fallback.example.com', $context->getHostname()); + $this->assertSame('', $context->getTablePrefix()); + $this->assertSame(PMF_CONFIG_DIR, $context->getConfigDir()); + $this->assertSame('free', $context->getPlan()); + $this->assertNull($context->getQuotas()->getMaxFaqs()); + $this->assertNull($context->getQuotas()->getMaxAttachmentSize()); + $this->assertNull($context->getQuotas()->getMaxUsers()); + $this->assertNull($context->getQuotas()->getMaxApiRequests()); + $this->assertNull($context->getQuotas()->getMaxCategories()); + } +} diff --git a/tests/phpMyFAQ/Tenant/TenantContextTest.php b/tests/phpMyFAQ/Tenant/TenantContextTest.php new file mode 100644 index 0000000000..429883e445 --- /dev/null +++ b/tests/phpMyFAQ/Tenant/TenantContextTest.php @@ -0,0 +1,33 @@ +assertSame(42, $context->getTenantId()); + $this->assertSame('acme.example.com', $context->getHostname()); + $this->assertSame('acme_', $context->getTablePrefix()); + $this->assertSame('/tmp/acme-config', $context->getConfigDir()); + $this->assertSame('pro', $context->getPlan()); + $this->assertSame($quotas, $context->getQuotas()); + } +} diff --git a/tests/phpMyFAQ/Tenant/TenantQuotasTest.php b/tests/phpMyFAQ/Tenant/TenantQuotasTest.php new file mode 100644 index 0000000000..cb197f8de2 --- /dev/null +++ b/tests/phpMyFAQ/Tenant/TenantQuotasTest.php @@ -0,0 +1,32 @@ +assertNull($quotas->getMaxFaqs()); + $this->assertNull($quotas->getMaxAttachmentSize()); + $this->assertNull($quotas->getMaxUsers()); + $this->assertNull($quotas->getMaxApiRequests()); + $this->assertNull($quotas->getMaxCategories()); + } + + public function testReturnsConfiguredValues(): void + { + $quotas = new TenantQuotas(100, 2048, 25, 1000, 50); + + $this->assertSame(100, $quotas->getMaxFaqs()); + $this->assertSame(2048, $quotas->getMaxAttachmentSize()); + $this->assertSame(25, $quotas->getMaxUsers()); + $this->assertSame(1000, $quotas->getMaxApiRequests()); + $this->assertSame(50, $quotas->getMaxCategories()); + } +} From ab330747ce4b702fccb53b73379c60312208a8dc Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 19:31:22 +0100 Subject: [PATCH 4/9] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Bootstrapper.php | 37 +++- .../MultisiteConfigurationLocator.php | 5 +- phpmyfaq/src/phpMyFAQ/Instance/Client.php | 170 +++++++++++++++++- phpmyfaq/src/phpMyFAQ/Instance/Database.php | 44 ++++- .../src/phpMyFAQ/Instance/Database/Mysqli.php | 15 +- .../phpMyFAQ/Instance/Database/PdoMysql.php | 15 +- .../phpMyFAQ/Instance/Database/PdoPgsql.php | 15 +- .../phpMyFAQ/Instance/Database/PdoSqlsrv.php | 21 ++- .../src/phpMyFAQ/Instance/Database/Pgsql.php | 15 +- .../src/phpMyFAQ/Instance/Database/Sqlsrv.php | 21 ++- phpmyfaq/src/phpMyFAQ/Instance/Setup.php | 35 +++- tests/phpMyFAQ/Instance/ClientTest.php | 83 ++++++++- tests/phpMyFAQ/Instance/DatabaseTest.php | 53 ++++++ tests/phpMyFAQ/Instance/SetupTest.php | 82 +++++++++ 14 files changed, 563 insertions(+), 48 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index 0f406bbd25..a8f9179d2b 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -27,6 +27,7 @@ use phpMyFAQ\Core\Exception; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Database\DatabaseDriver; +use RuntimeException; use Symfony\Component\HttpFoundation\Request; class Bootstrapper @@ -170,6 +171,8 @@ private function connectDatabase(string $databaseFile): void /** * Switches to the tenant's schema or database after connection, if configured. + * + * @throws RuntimeException */ private function switchToTenantSchema(DatabaseConfiguration $dbConfig): void { @@ -178,18 +181,38 @@ private function switchToTenantSchema(DatabaseConfiguration $dbConfig): void return; } + $schema = trim($schema); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + throw new RuntimeException('Invalid tenant schema identifier.'); + } + $dbType = $dbConfig->getType(); - if (str_contains($dbType, 'mysql') || str_contains($dbType, 'mysqli')) { - $this->db->query(sprintf('USE `%s`', $schema)); - return; - } + try { + if (str_contains($dbType, 'mysql')) { + $quotedSchema = sprintf('`%s`', str_replace('`', '``', $schema)); + $result = $this->db->query(sprintf('USE %s', $quotedSchema)); + if ($result === false) { + throw new RuntimeException('Failed to switch to tenant schema for MySQL.'); + } + return; + } - if (str_contains($dbType, 'pgsql')) { - $this->db->query(sprintf('SET search_path TO "%s"', $schema)); + if (str_contains($dbType, 'pgsql')) { + $quotedSchema = sprintf('"%s"', str_replace('"', '""', $schema)); + $result = $this->db->query(sprintf('SET search_path TO %s', $quotedSchema)); + if ($result === false) { + throw new RuntimeException('Failed to switch to tenant schema for PostgreSQL.'); + } + } + } catch (Exception $exception) { + throw new RuntimeException( + 'Failed to switch to tenant schema: ' . $exception->getMessage(), + previous: $exception, + ); } - // SQL Server uses schema prefix in queries; no global switch needed. + // SQL Server uses a schema prefix in queries; no global switch needed. } private function configureLdap(): void diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php index 664df53b36..b35cde32a0 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php @@ -32,7 +32,7 @@ public static function locateConfigurationDirectory(Request $request, string $co $parsed = parse_url($protocol . '://' . $host . $scriptName); if (isset($parsed['host']) && $parsed['host'] !== '') { - // 1. Try exact hostname match (existing behavior) + // 1. Try an exact hostname match (existing behavior) $configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $parsed['host']; if (is_dir($configDir)) { @@ -64,6 +64,7 @@ public static function locateConfigurationDirectory(Request $request, string $co */ public static function extractTenantFromSubdomain(string $host): ?string { + $host = strtolower($host); $baseDomain = getenv('PMF_MULTISITE_BASE_DOMAIN'); if ($baseDomain === false || $baseDomain === '') { return null; @@ -76,7 +77,7 @@ public static function extractTenantFromSubdomain(string $host): ?string return null; } - $tenant = substr($host, offset: 0, length: -strlen($suffix)); + $tenant = strtolower(substr($host, offset: 0, length: -strlen($suffix))); if ($tenant === '' || str_contains($tenant, '.')) { return null; } diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 0d90880a71..3c31ca0d40 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -105,9 +105,8 @@ public function createClientDatabase(string $tenantIdentifier, ?TenantIsolationM match ($mode) { TenantIsolationMode::PREFIX => $this->createClientTables($tenantIdentifier), - TenantIsolationMode::SCHEMA, TenantIsolationMode::DATABASE => $this->createClientTablesWithSchema( - $tenantIdentifier, - ), + TenantIsolationMode::SCHEMA => $this->createClientTablesWithSchema($tenantIdentifier), + TenantIsolationMode::DATABASE => $this->createClientTablesWithDatabase($tenantIdentifier), }; } @@ -115,25 +114,99 @@ public function createClientDatabase(string $tenantIdentifier, ?TenantIsolationM * Creates all tables in a dedicated schema or database for tenant isolation. * * @param string $schema Schema or database name for the tenant + * @throws Exception */ private function createClientTablesWithSchema(string $schema): void { try { + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + throw new Exception('Invalid tenant schema identifier.'); + } + $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType()); - $instanceDatabase->createTables('', $schema); + if (!$instanceDatabase->createTables('', $schema)) { + throw new Exception('Failed to create tenant tables in schema.'); + } $this->copyBaseDataToSchema($schema); + } catch (Exception $exception) { + $this->configuration->getLogger()->error('Failed to create tenant schema tables.', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + 'schema' => $schema, + ]); + throw $exception; + } + } + + /** + * Creates all tables in a dedicated database for tenant isolation. + * + * Supported drivers: PostgreSQL, SQL Server. + */ + private function createClientTablesWithDatabase(string $databaseName): void + { + if (!preg_match('/^[A-Za-z0-9_]+$/', $databaseName)) { + return; + } + + $credentials = $this->getDatabaseCredentials(); + if ($credentials === null) { + return; + } + + $sourcePrefix = Database::getTablePrefix(); + $targetPrefix = $sourcePrefix ?? ''; + $seedRows = $this->collectSeedRows($sourcePrefix ?? ''); + $sourceDatabase = $credentials['database']; + + try { + if (!InstanceDatabase::createTenantDatabase($this->configuration, Database::getType(), $databaseName)) { + return; + } + + if (!$this->configuration->getDb()->connect( + $credentials['server'], + $credentials['user'], + $credentials['password'], + $databaseName, + $credentials['port'], + )) { + return; + } + + $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType()); + if (!$instanceDatabase->createTables($targetPrefix)) { + return; + } + + $this->insertSeedRows($targetPrefix, $seedRows); } catch (Exception) { + } finally { + $this->configuration->getDb()->connect( + $credentials['server'], + $credentials['user'], + $credentials['password'], + $sourceDatabase, + $credentials['port'], + ); } } /** * Copies base configuration, rights, and user data into a tenant's schema/database. + * + * @throws Exception */ private function copyBaseDataToSchema(string $schema): void { + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + throw new Exception('Invalid tenant schema identifier.'); + } + $dbType = Database::getType(); $sourcePrefix = Database::getTablePrefix(); + $escapedClientUrl = $this->configuration->getDb()->escape($this->clientUrl); $targetPrefix = sprintf('`%s`.', $schema); @@ -151,7 +224,7 @@ private function copyBaseDataToSchema(string $schema): void ->query(sprintf( "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'", $targetPrefix, - $this->clientUrl, + $escapedClientUrl, )); $this->configuration @@ -167,10 +240,89 @@ private function copyBaseDataToSchema(string $schema): void )); } + private function getDatabaseCredentials(): ?array + { + $databaseFile = PMF_CONFIG_DIR . '/database.php'; + if (!file_exists($databaseFile)) { + return null; + } + + $DB = []; + include $databaseFile; + + if (!isset($DB['server'], $DB['user'], $DB['password'], $DB['db'])) { + return null; + } + + return [ + 'server' => (string) $DB['server'], + 'port' => ($DB['port'] ?? '') === '' ? null : (int) $DB['port'], + 'user' => (string) $DB['user'], + 'password' => (string) $DB['password'], + 'database' => (string) $DB['db'], + ]; + } + + /** + * Reads seed data from source database before switching to the tenant database. + */ + private function collectSeedRows(string $prefix): array + { + $tables = [ + 'faqconfig' => sprintf('SELECT * FROM %sfaqconfig', $prefix), + 'faqright' => sprintf('SELECT * FROM %sfaqright', $prefix), + 'faquser_right' => sprintf('SELECT * FROM %sfaquser_right WHERE user_id = 1', $prefix), + ]; + + $rows = []; + foreach ($tables as $table => $query) { + $result = $this->configuration->getDb()->query($query); + $rows[$table] = $result === false ? [] : $this->configuration->getDb()->fetchAll($result) ?? []; + } + + return $rows; + } + + private function insertSeedRows(string $prefix, array $seedRows): void + { + $this->insertRows($prefix . 'faqconfig', $seedRows['faqconfig'] ?? []); + $this->configuration + ->getDb() + ->query(sprintf( + "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'", + $prefix, + $this->configuration->getDb()->escape($this->clientUrl), + )); + + $this->insertRows($prefix . 'faqright', $seedRows['faqright'] ?? []); + $this->insertRows($prefix . 'faquser_right', $seedRows['faquser_right'] ?? []); + } + + private function insertRows(string $table, array $rows): void + { + foreach ($rows as $row) { + $rowData = (array) $row; + $columns = array_keys($rowData); + $values = array_map(fn(mixed $value): string => $value === null + ? 'NULL' + : sprintf("'%s'", $this->configuration->getDb()->escape((string) $value)), array_values($rowData)); + + $query = sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $table, + implode(', ', $columns), + implode(', ', $values), + ); + + $this->configuration->getDb()->query($query); + } + } + /** * Creates all tables with the given table prefix from the primary tables. * * @param string $prefix SQL table prefix + * @throws Exception */ public function createClientTables(string $prefix): void { @@ -208,7 +360,13 @@ public function createClientTables(string $prefix): void $prefix, Database::getTablePrefix(), )); - } catch (Exception) { + } catch (Exception $exception) { + $this->configuration->getLogger()->error('Failed to create tenant prefix tables.', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + 'prefix' => $prefix, + ]); + throw $exception; } } diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database.php b/phpmyfaq/src/phpMyFAQ/Instance/Database.php index 416f7c76f0..e379c05c28 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database.php @@ -1,7 +1,7 @@ getDb()->escape($databaseName), + ); + $existsResult = $configuration->getDb()->query($existsQuery); + if ($existsResult !== false && $configuration->getDb()->numRows($existsResult) > 0) { + return true; + } + + return (bool) $configuration->getDb()->query(sprintf('CREATE DATABASE "%s"', $databaseName)); + } + + if (str_contains($normalizedType, 'sqlsrv')) { + return (bool) $configuration + ->getDb() + ->query(sprintf( + "IF DB_ID('%s') IS NULL CREATE DATABASE [%s]", + $configuration->getDb()->escape($databaseName), + $databaseName, + )); + } + + return false; + } + /** * Executes all DROP TABLE statements. */ diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php index 16837811b6..e74faceaf4 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php @@ -401,8 +401,19 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema)); - $this->configuration->getDb()->query(sprintf('USE `%s`', $schema)); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $quotedSchema = sprintf('`%s`', str_replace('`', '``', $schema)); + + if (!$this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS %s', $quotedSchema))) { + return false; + } + + if (!$this->configuration->getDb()->query(sprintf('USE %s', $quotedSchema))) { + return false; + } } foreach ($this->createTableStatements as $createTableStatement) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php index 4b02e41c0c..4b1e0368db 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php @@ -400,8 +400,19 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema)); - $this->configuration->getDb()->query(sprintf('USE `%s`', $schema)); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $quotedSchema = sprintf('`%s`', str_replace('`', '``', $schema)); + + if (!$this->configuration->getDb()->query(sprintf('CREATE DATABASE IF NOT EXISTS %s', $quotedSchema))) { + return false; + } + + if (!$this->configuration->getDb()->query(sprintf('USE %s', $quotedSchema))) { + return false; + } } foreach ($this->createTableStatements as $createTableStatement) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php index 515a7b0589..c18df6c467 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php @@ -390,8 +390,19 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema)); - $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $quotedSchema = sprintf('"%s"', str_replace('"', '""', $schema)); + + if (!$this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS %s', $quotedSchema))) { + return false; + } + + if (!$this->configuration->getDb()->query(sprintf('SET search_path TO %s', $quotedSchema))) { + return false; + } } foreach ($this->createTableStatements as $key => $stmt) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php index a4ab9dae7c..ad2514bda5 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php @@ -387,13 +387,20 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration - ->getDb() - ->query(sprintf( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')", - $schema, - $schema, - )); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $schemaLiteral = str_replace("'", "''", $schema); + $schemaIdentifier = sprintf('[%s]', str_replace(']', ']]', $schema)); + + if (!$this->configuration->getDb()->query(sprintf( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA %s')", + $schemaLiteral, + $schemaIdentifier, + ))) { + return false; + } } foreach ($this->createTableStatements as $createTableStatement) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php index aa411f1672..64513cde87 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php @@ -391,8 +391,19 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema)); - $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $quotedSchema = sprintf('"%s"', str_replace('"', '""', $schema)); + + if (!$this->configuration->getDb()->query(sprintf('CREATE SCHEMA IF NOT EXISTS %s', $quotedSchema))) { + return false; + } + + if (!$this->configuration->getDb()->query(sprintf('SET search_path TO %s', $quotedSchema))) { + return false; + } } foreach ($this->createTableStatements as $key => $stmt) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php index 2fc2a0692e..d1ff1c486b 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php @@ -388,13 +388,20 @@ public function __construct(Configuration $configuration) public function createTables(string $prefix = '', ?string $schema = null): bool { if ($schema !== null && $schema !== '') { - $this->configuration - ->getDb() - ->query(sprintf( - "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')", - $schema, - $schema, - )); + if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) { + return false; + } + + $schemaLiteral = str_replace("'", "''", $schema); + $schemaIdentifier = sprintf('[%s]', str_replace(']', ']]', $schema)); + + if (!$this->configuration->getDb()->query(sprintf( + "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA %s')", + $schemaLiteral, + $schemaIdentifier, + ))) { + return false; + } } foreach ($this->createTableStatements as $createTableStatement) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php index c02ad6227b..dcfb830ae8 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Setup.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Setup.php @@ -117,37 +117,56 @@ public function createDatabaseFile(array $data, string $folder = '/content/core/ throw new Exception('File [' . $this->rootDir . $folder . '] is not writable.'); } + $schema = (string) ($data['dbSchema'] ?? ''); + if ($schema !== '' && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $schema)) { + throw new Exception('Invalid database schema name.'); + } + + $dbServer = $this->escapeForSingleQuotedPhpString((string) ($data['dbServer'] ?? '')); + $dbPort = $this->escapeForSingleQuotedPhpString((string) ($data['dbPort'] ?? '')); + $dbUser = $this->escapeForSingleQuotedPhpString((string) ($data['dbUser'] ?? '')); + $dbPassword = $this->escapeForSingleQuotedPhpString((string) ($data['dbPassword'] ?? '')); + $dbDatabaseName = $this->escapeForSingleQuotedPhpString((string) ($data['dbDatabaseName'] ?? '')); + $dbPrefix = $this->escapeForSingleQuotedPhpString((string) ($data['dbPrefix'] ?? '')); + $dbType = $this->escapeForSingleQuotedPhpString((string) ($data['dbType'] ?? '')); + $dbSchema = $this->escapeForSingleQuotedPhpString($schema); + return file_put_contents( $this->rootDir . $folder . '/database.php', 'createMock(DatabaseDriver::class); $this->configuration->method('getDb')->willReturn($dbMock); - $dbMock->expects($this->atLeastOnce())->method('query'); + $queries = []; + $dbMock->method('query')->willReturnCallback( + static function (string $query) use (&$queries): bool { + $queries[] = $query; + return true; + } + ); + $dbMock->method('escape')->willReturnCallback(static fn (string $value): string => $value); $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tenant_schema', TenantIsolationMode::SCHEMA); + + $this->assertNotEmpty($queries); + $this->assertTrue($this->queryContains($queries, 'SET search_path TO "tenant_schema"')); + $this->assertContains('INSERT INTO "tenant_schema".faqconfig SELECT * FROM faqconfig', $queries); + $this->assertContains('INSERT INTO "tenant_schema".faqright SELECT * FROM faqright', $queries); + $this->assertContains( + 'INSERT INTO "tenant_schema".faquser_right SELECT * FROM faquser_right WHERE user_id = 1', + $queries, + ); + $this->assertContains( + "UPDATE \"tenant_schema\".faqconfig SET config_value = 'https://tenant.example.com' WHERE config_name = 'main.referenceURL'", + $queries, + ); } public function testCreateClientDatabaseWithDatabaseMode(): void { + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + $dbMock = $this->createMock(DatabaseDriver::class); $this->configuration->method('getDb')->willReturn($dbMock); - $dbMock->expects($this->atLeastOnce())->method('query'); + $dbMock->expects($this->exactly(2)) + ->method('connect') + ->willReturnCallback(static function ( + string $server, + string $user, + string $password, + string $database, + ?int $port = null, + ): bool { + return $database === 'tenant_db' || $database === ''; + }); + + $queries = []; + $dbMock->method('query')->willReturnCallback( + static function (string $query) use (&$queries): mixed { + $queries[] = $query; + if (str_starts_with($query, 'SELECT * FROM faq')) { + return new \stdClass(); + } + if (str_starts_with($query, 'SELECT 1 FROM pg_database')) { + return new \stdClass(); + } + return true; + } + ); + $dbMock->method('fetchAll')->willReturn([]); + $dbMock->method('numRows')->willReturn(0); + $dbMock->method('escape')->willReturnCallback(static fn (string $value): string => $value); $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tenant_db', TenantIsolationMode::DATABASE); + + $this->assertNotEmpty($queries); + $this->assertContains('SELECT * FROM faqconfig', $queries); + $this->assertContains('SELECT * FROM faqright', $queries); + $this->assertContains('SELECT * FROM faquser_right WHERE user_id = 1', $queries); + $this->assertContains("SELECT 1 FROM pg_database WHERE datname = 'tenant_db'", $queries); + $this->assertContains('CREATE DATABASE "tenant_db"', $queries); + $this->assertContains( + "UPDATE faqconfig SET config_value = 'https://tenant.example.com' WHERE config_name = 'main.referenceURL'", + $queries, + ); + } + + /** + * @param string[] $queries + */ + private function queryContains(array $queries, string $needle): bool + { + foreach ($queries as $query) { + if (str_contains($query, $needle)) { + return true; + } + } + + return false; } public function testCreateClientDatabaseDefaultsToPrefix(): void diff --git a/tests/phpMyFAQ/Instance/DatabaseTest.php b/tests/phpMyFAQ/Instance/DatabaseTest.php index da612fe512..a972ab1259 100644 --- a/tests/phpMyFAQ/Instance/DatabaseTest.php +++ b/tests/phpMyFAQ/Instance/DatabaseTest.php @@ -71,4 +71,57 @@ public function testDropTablesWithFailure(): void $this->assertFalse($result); } + + public function testCreateTenantDatabaseReturnsFalseForInvalidDatabaseName(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + $dbMock->expects($this->never())->method('query'); + + $result = Database::createTenantDatabase($this->configuration, 'pgsql', 'tenant-db'); + + $this->assertFalse($result); + } + + public function testCreateTenantDatabaseCreatesPgsqlDatabaseWhenMissing(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->method('escape')->willReturnArgument(0); + $queryCall = 0; + $dbMock->expects($this->exactly(2)) + ->method('query') + ->willReturnCallback(function (string $query) use (&$queryCall): mixed { + if ($queryCall === 0) { + $this->assertStringContainsString('SELECT 1 FROM pg_database', $query); + $queryCall++; + return new \stdClass(); + } + + $this->assertStringContainsString('CREATE DATABASE "tenantdb"', $query); + return true; + }); + $dbMock->expects($this->once())->method('numRows')->willReturn(0); + + $result = Database::createTenantDatabase($this->configuration, 'pgsql', 'tenantdb'); + + $this->assertTrue($result); + } + + public function testCreateTenantDatabaseCreatesSqlServerDatabaseWhenMissing(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); + + $dbMock->method('escape')->willReturnArgument(0); + $dbMock->expects($this->once()) + ->method('query') + ->with($this->stringContains("IF DB_ID('tenantdb') IS NULL CREATE DATABASE [tenantdb]")) + ->willReturn(true); + + $result = Database::createTenantDatabase($this->configuration, 'sqlsrv', 'tenantdb'); + + $this->assertTrue($result); + } } diff --git a/tests/phpMyFAQ/Instance/SetupTest.php b/tests/phpMyFAQ/Instance/SetupTest.php index f7901f3c8a..aa29cac8e5 100644 --- a/tests/phpMyFAQ/Instance/SetupTest.php +++ b/tests/phpMyFAQ/Instance/SetupTest.php @@ -15,6 +15,7 @@ class SetupTest extends TestCase private Setup $setup; private Configuration $configuration; private User $user; + private string $tmpRootDir; /** * @throws \PHPUnit\Framework\MockObject\Exception @@ -26,6 +27,16 @@ protected function setUp(): void $this->setup = new Setup(); $this->configuration = $this->createStub(Configuration::class); $this->user = $this->createStub(User::class); + $this->tmpRootDir = sys_get_temp_dir() . '/phpmyfaq-setup-test-' . uniqid('', true); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if (is_dir($this->tmpRootDir)) { + $this->removeDirectory($this->tmpRootDir); + } } public function testSetRootDir(): void @@ -55,4 +66,75 @@ public function testCreateDatabaseFile(): void $this->expectException(Exception::class); $this->setup->createDatabaseFile($data, $folder); } + + public function testCreateDatabaseFileWithValidSchemaWritesEscapedConfig(): void + { + mkdir($this->tmpRootDir . '/content/core/config', 0777, true); + $this->setup->setRootDir($this->tmpRootDir); + + $data = [ + 'dbServer' => "localhost\\db", + 'dbPort' => '3306', + 'dbUser' => "root'user", + 'dbPassword' => 'pass', + 'dbDatabaseName' => 'phpmyfaq', + 'dbPrefix' => 'pmf_', + 'dbType' => 'mysql', + 'dbSchema' => 'tenant_schema1', + ]; + + $result = $this->setup->createDatabaseFile($data); + $this->assertIsInt($result); + + $content = file_get_contents($this->tmpRootDir . '/content/core/config/database.php'); + $this->assertNotFalse($content); + $this->assertStringContainsString("\$DB['schema'] = 'tenant_schema1';", $content); + $this->assertStringContainsString("\$DB['user'] = 'root\\'user';", $content); + $this->assertStringContainsString("\$DB['server'] = 'localhost\\\\db';", $content); + } + + public function testCreateDatabaseFileRejectsInvalidSchema(): void + { + mkdir($this->tmpRootDir . '/content/core/config', 0777, true); + $this->setup->setRootDir($this->tmpRootDir); + + $data = [ + 'dbServer' => 'localhost', + 'dbPort' => '3306', + 'dbUser' => 'root', + 'dbPassword' => 'password', + 'dbDatabaseName' => 'phpmyfaq', + 'dbPrefix' => 'pmf_', + 'dbType' => 'mysql', + 'dbSchema' => "bad-schema'; die('x'); //", + ]; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid database schema name.'); + $this->setup->createDatabaseFile($data); + } + + private function removeDirectory(string $directory): void + { + $entries = scandir($directory); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . '/' . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + continue; + } + + unlink($path); + } + + rmdir($directory); + } } From 54e559e26313794c156ac9b56d081ba5b4bcd285 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 21:30:03 +0100 Subject: [PATCH 5/9] feat: added tenant livecycle events --- phpmyfaq/src/phpMyFAQ/Bootstrapper.php | 2 +- .../MultisiteConfigurationLocator.php | 2 +- phpmyfaq/src/phpMyFAQ/Instance/Client.php | 36 +++++- phpmyfaq/src/phpMyFAQ/Instance/Database.php | 11 +- .../phpMyFAQ/Tenant/TenantContextResolver.php | 2 +- .../phpMyFAQ/Tenant/TenantEventDispatcher.php | 86 ++++++++++++++ .../phpMyFAQ/Tenant/TenantLifecycleEvent.php | 44 ++++++++ phpmyfaq/src/services.php | 8 ++ tests/phpMyFAQ/Instance/ClientTest.php | 24 +++- tests/phpMyFAQ/Instance/DatabaseTest.php | 6 +- .../Tenant/TenantContextResolverTest.php | 3 +- .../Tenant/TenantEventDispatcherTest.php | 105 ++++++++++++++++++ .../Tenant/TenantLifecycleEventTest.php | 18 +++ 13 files changed, 331 insertions(+), 16 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Tenant/TenantEventDispatcher.php create mode 100644 phpmyfaq/src/phpMyFAQ/Tenant/TenantLifecycleEvent.php create mode 100644 tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php create mode 100644 tests/phpMyFAQ/Tenant/TenantLifecycleEventTest.php diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index a8f9179d2b..a5d28077cf 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -205,7 +205,7 @@ private function switchToTenantSchema(DatabaseConfiguration $dbConfig): void throw new RuntimeException('Failed to switch to tenant schema for PostgreSQL.'); } } - } catch (Exception $exception) { + } catch (\Throwable $exception) { throw new RuntimeException( 'Failed to switch to tenant schema: ' . $exception->getMessage(), previous: $exception, diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php index b35cde32a0..23af9eedf8 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration/MultisiteConfigurationLocator.php @@ -70,7 +70,7 @@ public static function extractTenantFromSubdomain(string $host): ?string return null; } - $baseDomain = ltrim($baseDomain, characters: '.'); + $baseDomain = strtolower(ltrim($baseDomain, characters: '.')); $suffix = '.' . $baseDomain; if (!str_ends_with($host, $suffix)) { diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 3c31ca0d40..38ed9cb07d 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -143,11 +143,21 @@ private function createClientTablesWithSchema(string $schema): void * Creates all tables in a dedicated database for tenant isolation. * * Supported drivers: PostgreSQL, SQL Server. + * + * @throws Exception */ private function createClientTablesWithDatabase(string $databaseName): void { if (!preg_match('/^[A-Za-z0-9_]+$/', $databaseName)) { - return; + throw new Exception('Invalid tenant database identifier.'); + } + + $dbType = strtolower(Database::getType()); + if (!str_contains($dbType, 'pgsql') && !str_contains($dbType, 'sqlsrv')) { + throw new Exception(sprintf( + 'Database-per-tenant isolation is not supported for driver "%s". Use PostgreSQL or SQL Server.', + Database::getType(), + )); } $credentials = $this->getDatabaseCredentials(); @@ -213,6 +223,8 @@ private function copyBaseDataToSchema(string $schema): void if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql')) { $targetPrefix = sprintf('"%s".', $schema); $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + } elseif (str_contains($dbType, 'sqlsrv') || str_contains($dbType, 'Sqlsrv')) { + $targetPrefix = sprintf('[%s].', $schema); } $this->configuration @@ -302,7 +314,7 @@ private function insertRows(string $table, array $rows): void { foreach ($rows as $row) { $rowData = (array) $row; - $columns = array_keys($rowData); + $quotedColumns = array_map($this->quoteIdentifier(...), array_keys($rowData)); $values = array_map(fn(mixed $value): string => $value === null ? 'NULL' : sprintf("'%s'", $this->configuration->getDb()->escape((string) $value)), array_values($rowData)); @@ -310,7 +322,7 @@ private function insertRows(string $table, array $rows): void $query = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', $table, - implode(', ', $columns), + implode(', ', $quotedColumns), implode(', ', $values), ); @@ -318,6 +330,24 @@ private function insertRows(string $table, array $rows): void } } + /** + * Quotes a column or table identifier for the current database driver. + */ + private function quoteIdentifier(string $name): string + { + $dbType = Database::getType(); + + if (str_contains($dbType, 'sqlsrv') || str_contains($dbType, 'Sqlsrv')) { + return sprintf('[%s]', str_replace(']', ']]', $name)); + } + + if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql') || str_contains($dbType, 'sqlite')) { + return sprintf('"%s"', str_replace('"', '""', $name)); + } + + return sprintf('`%s`', str_replace('`', '``', $name)); + } + /** * Creates all tables with the given table prefix from the primary tables. * diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database.php b/phpmyfaq/src/phpMyFAQ/Instance/Database.php index e379c05c28..23b1447396 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database.php @@ -127,14 +127,16 @@ public static function getInstance(): ?DriverInterface } /** - * Creates a dedicated tenant database for supported drivers. + * Creates a dedicated tenant database for supported drivers (PostgreSQL, SQL Server). + * + * @throws \RuntimeException if the driver does not support database-per-tenant isolation */ public static function createTenantDatabase(Configuration $configuration, string $type, string $databaseName): bool { $normalizedType = strtolower($type); if (!preg_match('/^[A-Za-z0-9_]+$/', $databaseName)) { - return false; + throw new \InvalidArgumentException(sprintf('Invalid tenant database identifier: "%s".', $databaseName)); } if (str_contains($normalizedType, 'pgsql')) { @@ -160,7 +162,10 @@ public static function createTenantDatabase(Configuration $configuration, string )); } - return false; + throw new \RuntimeException(sprintf( + 'Database-per-tenant isolation is not supported for driver "%s". Use PostgreSQL or SQL Server.', + $type, + )); } /** diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php index c216e5245e..34dbdce9ac 100644 --- a/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantContextResolver.php @@ -30,7 +30,7 @@ public function resolve(?Request $request = null): TenantContext $hostname = $request->getHost(); if ($hostname === '') { - $hostname = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $hostname = 'localhost'; } $configDir = defined('PMF_CONFIG_DIR') ? PMF_CONFIG_DIR : ''; diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantEventDispatcher.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantEventDispatcher.php new file mode 100644 index 0000000000..a8a53edc2c --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantEventDispatcher.php @@ -0,0 +1,86 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Tenant; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; + +class TenantEventDispatcher +{ + public const string TENANT_CREATED = 'tenant.created'; + public const string TENANT_SUSPENDED = 'tenant.suspended'; + public const string TENANT_DELETED = 'tenant.deleted'; + public const string TENANT_PLAN_CHANGED = 'tenant.plan.changed'; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + ) { + } + + /** + * @param array $context + */ + public function dispatchTenantCreated(int $tenantId, array $context = []): TenantLifecycleEvent + { + return $this->dispatch(self::TENANT_CREATED, $tenantId, $context); + } + + /** + * @param array $context + */ + public function dispatchTenantSuspended(int $tenantId, array $context = []): TenantLifecycleEvent + { + return $this->dispatch(self::TENANT_SUSPENDED, $tenantId, $context); + } + + /** + * @param array $context + */ + public function dispatchTenantDeleted(int $tenantId, array $context = []): TenantLifecycleEvent + { + return $this->dispatch(self::TENANT_DELETED, $tenantId, $context); + } + + /** + * @param array $context + */ + public function dispatchTenantPlanChanged( + int $tenantId, + string $oldPlan, + string $newPlan, + array $context = [], + ): TenantLifecycleEvent { + $eventContext = $context; + $eventContext['oldPlan'] = $oldPlan; + $eventContext['newPlan'] = $newPlan; + + return $this->dispatch(self::TENANT_PLAN_CHANGED, $tenantId, $eventContext); + } + + /** + * @param array $context + */ + private function dispatch(string $eventName, int $tenantId, array $context): TenantLifecycleEvent + { + $event = new TenantLifecycleEvent($tenantId, $context); + $this->eventDispatcher->dispatch($event, $eventName); + + return $event; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Tenant/TenantLifecycleEvent.php b/phpmyfaq/src/phpMyFAQ/Tenant/TenantLifecycleEvent.php new file mode 100644 index 0000000000..5a9d6cae98 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Tenant/TenantLifecycleEvent.php @@ -0,0 +1,44 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-08 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Tenant; + +use Symfony\Contracts\EventDispatcher\Event; + +class TenantLifecycleEvent extends Event +{ + public function __construct( + private readonly int $tenantId, + private readonly array $context = [], + ) { + } + + public function getTenantId(): int + { + return $this->tenantId; + } + + /** + * @return array + */ + public function getContext(): array + { + return $this->context; + } +} diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 041d042ac4..a2dbb08293 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -84,6 +84,7 @@ use phpMyFAQ\Tags; use phpMyFAQ\Tenant\TenantContext; use phpMyFAQ\Tenant\TenantContextResolver; +use phpMyFAQ\Tenant\TenantEventDispatcher; use phpMyFAQ\Translation\ContentTranslationService; use phpMyFAQ\Translation\TranslationProviderFactory; use phpMyFAQ\Translation\TranslationProviderInterface; @@ -93,6 +94,8 @@ use phpMyFAQ\User\UserSession; use phpMyFAQ\Visits; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Session\Session; @@ -113,6 +116,8 @@ // ========== Core Symfony framework services ========== $services->set('filesystem', Filesystem::class); + $services->set('phpmyfaq.event_dispatcher', EventDispatcher::class); + $services->alias(EventDispatcherInterface::class, 'phpmyfaq.event_dispatcher'); $services->set('session', Session::class); $services->alias(SessionInterface::class, 'session'); @@ -223,6 +228,9 @@ service('phpmyfaq.tenant.context-resolver'), 'resolve', ]); + $services->set('phpmyfaq.tenant.event-dispatcher', TenantEventDispatcher::class)->args([ + service('phpmyfaq.event_dispatcher'), + ]); $services->set('phpmyfaq.configuration', Configuration::class)->factory([ Configuration::class, diff --git a/tests/phpMyFAQ/Instance/ClientTest.php b/tests/phpMyFAQ/Instance/ClientTest.php index 6293dc622d..f9cd813c08 100644 --- a/tests/phpMyFAQ/Instance/ClientTest.php +++ b/tests/phpMyFAQ/Instance/ClientTest.php @@ -33,6 +33,13 @@ protected function setUp(): void $this->client->setFileSystem($this->filesystem); } + protected function tearDown(): void + { + Database::setTablePrefix(''); + + parent::tearDown(); + } + public function testCreateClientFolder(): void { $hostname = 'example.com'; @@ -144,12 +151,20 @@ static function (string $query) use (&$queries): bool { public function testCreateClientDatabaseWithDatabaseMode(): void { + // Guard: the DATABASE code path requires a database.php fixture so + // getDatabaseCredentials() returns non-null credentials. + $this->assertFileExists( + PMF_CONFIG_DIR . '/database.php', + 'Test fixture tests/content/core/config/database.php is required for the DATABASE isolation code path.', + ); + Database::factory('pdo_pgsql'); Database::setTablePrefix(''); $dbMock = $this->createMock(DatabaseDriver::class); $this->configuration->method('getDb')->willReturn($dbMock); + $connectCalls = []; $dbMock->expects($this->exactly(2)) ->method('connect') ->willReturnCallback(static function ( @@ -158,8 +173,9 @@ public function testCreateClientDatabaseWithDatabaseMode(): void string $password, string $database, ?int $port = null, - ): bool { - return $database === 'tenant_db' || $database === ''; + ) use (&$connectCalls): bool { + $connectCalls[] = $database; + return true; }); $queries = []; @@ -182,6 +198,10 @@ static function (string $query) use (&$queries): mixed { $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tenant_db', TenantIsolationMode::DATABASE); + // Verify connect was called first with the tenant DB, then restored to the source DB + $this->assertCount(2, $connectCalls); + $this->assertSame('tenant_db', $connectCalls[0]); + $this->assertNotEmpty($queries); $this->assertContains('SELECT * FROM faqconfig', $queries); $this->assertContains('SELECT * FROM faqright', $queries); diff --git a/tests/phpMyFAQ/Instance/DatabaseTest.php b/tests/phpMyFAQ/Instance/DatabaseTest.php index a972ab1259..02d1c246c6 100644 --- a/tests/phpMyFAQ/Instance/DatabaseTest.php +++ b/tests/phpMyFAQ/Instance/DatabaseTest.php @@ -72,15 +72,15 @@ public function testDropTablesWithFailure(): void $this->assertFalse($result); } - public function testCreateTenantDatabaseReturnsFalseForInvalidDatabaseName(): void + public function testCreateTenantDatabaseThrowsForInvalidDatabaseName(): void { $dbMock = $this->createMock(DatabaseDriver::class); $this->configuration->method('getDb')->willReturn($dbMock); $dbMock->expects($this->never())->method('query'); - $result = Database::createTenantDatabase($this->configuration, 'pgsql', 'tenant-db'); + $this->expectException(\InvalidArgumentException::class); - $this->assertFalse($result); + Database::createTenantDatabase($this->configuration, 'pgsql', 'tenant-db'); } public function testCreateTenantDatabaseCreatesPgsqlDatabaseWhenMissing(): void diff --git a/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php b/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php index b07483c946..6b79e146c1 100644 --- a/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php +++ b/tests/phpMyFAQ/Tenant/TenantContextResolverTest.php @@ -63,7 +63,6 @@ public function testResolveUsesRequestAndEnvironmentValues(): void */ public function testResolveFallsBackToDefaultsWhenEnvironmentIsMissing(): void { - $_SERVER['HTTP_HOST'] = 'fallback.example.com'; Database::setTablePrefix(''); $request = $this->createStub(Request::class); @@ -73,7 +72,7 @@ public function testResolveFallsBackToDefaultsWhenEnvironmentIsMissing(): void $context = $resolver->resolve($request); $this->assertSame(0, $context->getTenantId()); - $this->assertSame('fallback.example.com', $context->getHostname()); + $this->assertSame('localhost', $context->getHostname()); $this->assertSame('', $context->getTablePrefix()); $this->assertSame(PMF_CONFIG_DIR, $context->getConfigDir()); $this->assertSame('free', $context->getPlan()); diff --git a/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php b/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php new file mode 100644 index 0000000000..3322cd67f2 --- /dev/null +++ b/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php @@ -0,0 +1,105 @@ +addListener(TenantEventDispatcher::TENANT_CREATED, function ( + TenantLifecycleEvent $event, + string $eventName + ) use (&$receivedEvent, &$receivedName): void { + $receivedEvent = $event; + $receivedName = $eventName; + }); + + $tenantDispatcher = new TenantEventDispatcher($dispatcher); + $event = $tenantDispatcher->dispatchTenantCreated(10, ['source' => 'self-service']); + + $this->assertInstanceOf(TenantLifecycleEvent::class, $event); + $this->assertSame(10, $event->getTenantId()); + $this->assertSame(['source' => 'self-service'], $event->getContext()); + $this->assertSame(TenantEventDispatcher::TENANT_CREATED, $receivedName); + $this->assertSame($event, $receivedEvent); + } + + public function testDispatchTenantSuspended(): void + { + $dispatcher = new EventDispatcher(); + $receivedEvent = null; + + $dispatcher->addListener(TenantEventDispatcher::TENANT_SUSPENDED, function (TenantLifecycleEvent $event) use ( + &$receivedEvent + ): void { + $receivedEvent = $event; + }); + + $tenantDispatcher = new TenantEventDispatcher($dispatcher); + $event = $tenantDispatcher->dispatchTenantSuspended(11, ['reason' => 'billing']); + + $this->assertSame(11, $event->getTenantId()); + $this->assertSame(['reason' => 'billing'], $event->getContext()); + $this->assertSame($event, $receivedEvent); + } + + public function testDispatchTenantDeleted(): void + { + $dispatcher = new EventDispatcher(); + $receivedEvent = null; + + $dispatcher->addListener(TenantEventDispatcher::TENANT_DELETED, function (TenantLifecycleEvent $event) use ( + &$receivedEvent + ): void { + $receivedEvent = $event; + }); + + $tenantDispatcher = new TenantEventDispatcher($dispatcher); + $event = $tenantDispatcher->dispatchTenantDeleted(12, ['requestedBy' => 'admin']); + + $this->assertSame(12, $event->getTenantId()); + $this->assertSame(['requestedBy' => 'admin'], $event->getContext()); + $this->assertSame($event, $receivedEvent); + } + + public function testDispatchTenantPlanChangedAddsOldAndNewPlanToContext(): void + { + $dispatcher = new EventDispatcher(); + $receivedEvent = null; + $receivedName = null; + + $dispatcher->addListener(TenantEventDispatcher::TENANT_PLAN_CHANGED, function ( + TenantLifecycleEvent $event, + string $eventName + ) use (&$receivedEvent, &$receivedName): void { + $receivedEvent = $event; + $receivedName = $eventName; + }); + + $tenantDispatcher = new TenantEventDispatcher($dispatcher); + $event = $tenantDispatcher->dispatchTenantPlanChanged(13, 'basic', 'pro', ['source' => 'upgrade-flow']); + + $this->assertSame(13, $event->getTenantId()); + $this->assertSame( + [ + 'source' => 'upgrade-flow', + 'oldPlan' => 'basic', + 'newPlan' => 'pro', + ], + $event->getContext(), + ); + $this->assertSame(TenantEventDispatcher::TENANT_PLAN_CHANGED, $receivedName); + $this->assertSame($event, $receivedEvent); + } +} diff --git a/tests/phpMyFAQ/Tenant/TenantLifecycleEventTest.php b/tests/phpMyFAQ/Tenant/TenantLifecycleEventTest.php new file mode 100644 index 0000000000..f02e07767b --- /dev/null +++ b/tests/phpMyFAQ/Tenant/TenantLifecycleEventTest.php @@ -0,0 +1,18 @@ + 'pro', 'source' => 'test']); + + $this->assertSame(12, $event->getTenantId()); + $this->assertSame(['plan' => 'pro', 'source' => 'test'], $event->getContext()); + } +} From 4edc4e557ea7ec28c5cd6a618be520664b622013 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 21:54:04 +0100 Subject: [PATCH 6/9] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Instance/Client.php | 13 ++- .../MultisiteConfigurationLocatorTest.php | 4 +- tests/phpMyFAQ/Instance/ClientTest.php | 82 ++++++++++++++++--- tests/phpMyFAQ/Instance/DatabaseTest.php | 6 +- .../Tenant/TenantEventDispatcherTest.php | 8 +- 5 files changed, 92 insertions(+), 21 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 38ed9cb07d..56ee94f692 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -326,7 +326,18 @@ private function insertRows(string $table, array $rows): void implode(', ', $values), ); - $this->configuration->getDb()->query($query); + $result = $this->configuration->getDb()->query($query); + + if ($result === false) { + $dbError = $this->configuration->getDb()->error(); + $this->configuration->getLogger()->error('Failed to insert row into tenant table.', [ + 'table' => $table, + 'query' => $query, + 'error' => $dbError, + ]); + + throw new \RuntimeException(sprintf('Failed to insert row into %s: %s', $table, $dbError)); + } } } diff --git a/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php b/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php index 1f71c0d880..5bcefcd29d 100644 --- a/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php +++ b/tests/phpMyFAQ/Configuration/MultisiteConfigurationLocatorTest.php @@ -116,9 +116,7 @@ public function testExtractTenantFromSubdomainRejectsNestedSubdomains(): void { putenv('PMF_MULTISITE_BASE_DOMAIN=faq.example.com'); - $this->assertNull( - MultisiteConfigurationLocator::extractTenantFromSubdomain('deep.nested.faq.example.com') - ); + $this->assertNull(MultisiteConfigurationLocator::extractTenantFromSubdomain('deep.nested.faq.example.com')); putenv('PMF_MULTISITE_BASE_DOMAIN'); } diff --git a/tests/phpMyFAQ/Instance/ClientTest.php b/tests/phpMyFAQ/Instance/ClientTest.php index f9cd813c08..d9ac20f1f6 100644 --- a/tests/phpMyFAQ/Instance/ClientTest.php +++ b/tests/phpMyFAQ/Instance/ClientTest.php @@ -2,6 +2,7 @@ namespace phpMyFAQ\Instance; +use Monolog\Logger; use phpMyFAQ\Configuration; use phpMyFAQ\Database; use phpMyFAQ\Database\DatabaseDriver; @@ -9,6 +10,8 @@ use phpMyFAQ\Filesystem\Filesystem; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use RuntimeException; /** * Class ClientTest @@ -124,13 +127,13 @@ public function testCreateClientDatabaseWithSchemaMode(): void $this->configuration->method('getDb')->willReturn($dbMock); $queries = []; - $dbMock->method('query')->willReturnCallback( - static function (string $query) use (&$queries): bool { + $dbMock + ->method('query') + ->willReturnCallback(static function (string $query) use (&$queries): bool { $queries[] = $query; return true; - } - ); - $dbMock->method('escape')->willReturnCallback(static fn (string $value): string => $value); + }); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tenant_schema', TenantIsolationMode::SCHEMA); @@ -165,7 +168,8 @@ public function testCreateClientDatabaseWithDatabaseMode(): void $this->configuration->method('getDb')->willReturn($dbMock); $connectCalls = []; - $dbMock->expects($this->exactly(2)) + $dbMock + ->expects($this->exactly(2)) ->method('connect') ->willReturnCallback(static function ( string $server, @@ -179,8 +183,9 @@ public function testCreateClientDatabaseWithDatabaseMode(): void }); $queries = []; - $dbMock->method('query')->willReturnCallback( - static function (string $query) use (&$queries): mixed { + $dbMock + ->method('query') + ->willReturnCallback(static function (string $query) use (&$queries): mixed { $queries[] = $query; if (str_starts_with($query, 'SELECT * FROM faq')) { return new \stdClass(); @@ -189,11 +194,10 @@ static function (string $query) use (&$queries): mixed { return new \stdClass(); } return true; - } - ); + }); $dbMock->method('fetchAll')->willReturn([]); $dbMock->method('numRows')->willReturn(0); - $dbMock->method('escape')->willReturnCallback(static fn (string $value): string => $value); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tenant_db', TenantIsolationMode::DATABASE); @@ -243,4 +247,60 @@ public function testCreateClientDatabaseDefaultsToPrefix(): void putenv('PMF_TENANT_ISOLATION_MODE'); } + + public function testInsertRowsThrowsOnQueryFailure(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $dbMock->method('query')->willReturn(false); + $dbMock->method('error')->willReturn('Duplicate entry for key PRIMARY'); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + + $loggerMock + ->expects($this->once()) + ->method('error') + ->with('Failed to insert row into tenant table.', $this->callback(static function (array $context): bool { + return ( + $context['table'] === 'test_faqconfig' + && str_contains($context['query'], 'INSERT INTO test_faqconfig') + && $context['error'] === 'Duplicate entry for key PRIMARY' + ); + })); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to insert row into test_faqconfig: Duplicate entry for key PRIMARY'); + + $method = new ReflectionMethod(Client::class, 'insertRows'); + + $rows = [(object) ['config_name' => 'test.key', 'config_value' => 'test_value']]; + $method->invoke($this->client, 'test_faqconfig', $rows); + } + + public function testInsertRowsSucceedsWhenQueryReturnsTrue(): void + { + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $dbMock->method('query')->willReturn(true); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + + $loggerMock->expects($this->never())->method('error'); + + $method = new ReflectionMethod(Client::class, 'insertRows'); + + $rows = [ + (object) ['config_name' => 'key1', 'config_value' => 'value1'], + (object) ['config_name' => 'key2', 'config_value' => 'value2'], + ]; + $method->invoke($this->client, 'test_faqconfig', $rows); + + $this->addToAssertionCount(1); + } } diff --git a/tests/phpMyFAQ/Instance/DatabaseTest.php b/tests/phpMyFAQ/Instance/DatabaseTest.php index 02d1c246c6..1fe7a2277b 100644 --- a/tests/phpMyFAQ/Instance/DatabaseTest.php +++ b/tests/phpMyFAQ/Instance/DatabaseTest.php @@ -90,7 +90,8 @@ public function testCreateTenantDatabaseCreatesPgsqlDatabaseWhenMissing(): void $dbMock->method('escape')->willReturnArgument(0); $queryCall = 0; - $dbMock->expects($this->exactly(2)) + $dbMock + ->expects($this->exactly(2)) ->method('query') ->willReturnCallback(function (string $query) use (&$queryCall): mixed { if ($queryCall === 0) { @@ -115,7 +116,8 @@ public function testCreateTenantDatabaseCreatesSqlServerDatabaseWhenMissing(): v $this->configuration->method('getDb')->willReturn($dbMock); $dbMock->method('escape')->willReturnArgument(0); - $dbMock->expects($this->once()) + $dbMock + ->expects($this->once()) ->method('query') ->with($this->stringContains("IF DB_ID('tenantdb') IS NULL CREATE DATABASE [tenantdb]")) ->willReturn(true); diff --git a/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php b/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php index 3322cd67f2..8660a5398a 100644 --- a/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php +++ b/tests/phpMyFAQ/Tenant/TenantEventDispatcherTest.php @@ -19,7 +19,7 @@ public function testDispatchTenantCreated(): void $dispatcher->addListener(TenantEventDispatcher::TENANT_CREATED, function ( TenantLifecycleEvent $event, - string $eventName + string $eventName, ) use (&$receivedEvent, &$receivedName): void { $receivedEvent = $event; $receivedName = $eventName; @@ -41,7 +41,7 @@ public function testDispatchTenantSuspended(): void $receivedEvent = null; $dispatcher->addListener(TenantEventDispatcher::TENANT_SUSPENDED, function (TenantLifecycleEvent $event) use ( - &$receivedEvent + &$receivedEvent, ): void { $receivedEvent = $event; }); @@ -60,7 +60,7 @@ public function testDispatchTenantDeleted(): void $receivedEvent = null; $dispatcher->addListener(TenantEventDispatcher::TENANT_DELETED, function (TenantLifecycleEvent $event) use ( - &$receivedEvent + &$receivedEvent, ): void { $receivedEvent = $event; }); @@ -81,7 +81,7 @@ public function testDispatchTenantPlanChangedAddsOldAndNewPlanToContext(): void $dispatcher->addListener(TenantEventDispatcher::TENANT_PLAN_CHANGED, function ( TenantLifecycleEvent $event, - string $eventName + string $eventName, ) use (&$receivedEvent, &$receivedName): void { $receivedEvent = $event; $receivedName = $eventName; From f3994d937dc64dfcc25dfa352ac0efd8fe74668b Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Sun, 8 Feb 2026 21:57:07 +0100 Subject: [PATCH 7/9] fix: catch RuntimeExceptions as well --- phpmyfaq/src/phpMyFAQ/Bootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index a5d28077cf..9b9f7eff32 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -149,7 +149,7 @@ private function connectDatabase(string $databaseFile): void ); $this->switchToTenantSchema($dbConfig); - } catch (Exception $exception) { + } catch (Exception|RuntimeException $exception) { throw new DatabaseConnectionException( message: 'Database connection failed: ' . $exception->getMessage(), code: 500, From 05b18f414ceae53e24b4655fcff6aaabaa5d2380 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 07:46:37 +0100 Subject: [PATCH 8/9] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Instance/Client.php | 25 ++- phpmyfaq/src/phpMyFAQ/System.php | 4 + tests/phpMyFAQ/Instance/ClientTest.php | 197 ++++++++++++++++++++-- 3 files changed, 207 insertions(+), 19 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 56ee94f692..3278691916 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -162,7 +162,7 @@ private function createClientTablesWithDatabase(string $databaseName): void $credentials = $this->getDatabaseCredentials(); if ($credentials === null) { - return; + throw new Exception(sprintf('Database credentials not found for tenant database "%s".', $databaseName)); } $sourcePrefix = Database::getTablePrefix(); @@ -172,7 +172,7 @@ private function createClientTablesWithDatabase(string $databaseName): void try { if (!InstanceDatabase::createTenantDatabase($this->configuration, Database::getType(), $databaseName)) { - return; + throw new Exception(sprintf('Failed to create tenant database "%s".', $databaseName)); } if (!$this->configuration->getDb()->connect( @@ -182,16 +182,31 @@ private function createClientTablesWithDatabase(string $databaseName): void $databaseName, $credentials['port'], )) { - return; + throw new Exception(sprintf( + 'Failed to connect to tenant database "%s" on server "%s": %s', + $databaseName, + $credentials['server'], + $this->configuration->getDb()->error(), + )); } $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType()); if (!$instanceDatabase->createTables($targetPrefix)) { - return; + throw new Exception(sprintf( + 'Failed to create tables in tenant database "%s" with prefix "%s".', + $databaseName, + $targetPrefix, + )); } $this->insertSeedRows($targetPrefix, $seedRows); - } catch (Exception) { + } catch (Exception $exception) { + $this->configuration->getLogger()->error('Failed to create tenant database tables.', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + 'database' => $databaseName, + ]); + throw $exception; } finally { $this->configuration->getDb()->connect( $credentials['server'], diff --git a/phpmyfaq/src/phpMyFAQ/System.php b/phpmyfaq/src/phpMyFAQ/System.php index 3f08fd2d80..85a457acce 100644 --- a/phpmyfaq/src/phpMyFAQ/System.php +++ b/phpmyfaq/src/phpMyFAQ/System.php @@ -407,6 +407,10 @@ public function createHashes(): string try { foreach ($files as $file) { + if ($file->isDir()) { + continue; + } + if ('php' !== pathinfo((string) $file->getFilename(), PATHINFO_EXTENSION)) { continue; } diff --git a/tests/phpMyFAQ/Instance/ClientTest.php b/tests/phpMyFAQ/Instance/ClientTest.php index d9ac20f1f6..125d720598 100644 --- a/tests/phpMyFAQ/Instance/ClientTest.php +++ b/tests/phpMyFAQ/Instance/ClientTest.php @@ -4,6 +4,7 @@ use Monolog\Logger; use phpMyFAQ\Configuration; +use phpMyFAQ\Core\Exception; use phpMyFAQ\Database; use phpMyFAQ\Database\DatabaseDriver; use phpMyFAQ\Enums\TenantIsolationMode; @@ -154,12 +155,12 @@ public function testCreateClientDatabaseWithSchemaMode(): void public function testCreateClientDatabaseWithDatabaseMode(): void { - // Guard: the DATABASE code path requires a database.php fixture so - // getDatabaseCredentials() returns non-null credentials. - $this->assertFileExists( - PMF_CONFIG_DIR . '/database.php', - 'Test fixture tests/content/core/config/database.php is required for the DATABASE isolation code path.', - ); + if (!file_exists(PMF_CONFIG_DIR . '/database.php')) { + $this->markTestSkipped( + 'Test fixture tests/content/core/config/database.php is missing; ' + . 'the DATABASE isolation code path requires valid database credentials.', + ); + } Database::factory('pdo_pgsql'); Database::setTablePrefix(''); @@ -236,16 +237,18 @@ public function testCreateClientDatabaseDefaultsToPrefix(): void { putenv('PMF_TENANT_ISOLATION_MODE=prefix'); - $prefix = 'default_'; - $dbMock = $this->createMock(DatabaseDriver::class); - $this->configuration->method('getDb')->willReturn($dbMock); - - $dbMock->expects($this->atLeastOnce())->method('query'); + try { + $prefix = 'default_'; + $dbMock = $this->createMock(DatabaseDriver::class); + $this->configuration->method('getDb')->willReturn($dbMock); - $this->client->setClientUrl('https://default.example.com'); - $this->client->createClientDatabase($prefix); + $dbMock->expects($this->atLeastOnce())->method('query'); - putenv('PMF_TENANT_ISOLATION_MODE'); + $this->client->setClientUrl('https://default.example.com'); + $this->client->createClientDatabase($prefix); + } finally { + putenv('PMF_TENANT_ISOLATION_MODE'); + } } public function testInsertRowsThrowsOnQueryFailure(): void @@ -303,4 +306,170 @@ public function testInsertRowsSucceedsWhenQueryReturnsTrue(): void $this->addToAssertionCount(1); } + + public function testCreateClientTablesWithDatabaseThrowsOnMissingCredentials(): void + { + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Database credentials not found for tenant database "tenant_db".'); + + $method = new ReflectionMethod(Client::class, 'createClientTablesWithDatabase'); + + // Use a non-existent config dir to ensure getDatabaseCredentials returns null + $origConfigDir = PMF_CONFIG_DIR; + // We can't change the constant, but getDatabaseCredentials reads from PMF_CONFIG_DIR/database.php. + // Instead, test by invoking on a client whose config dir won't have the file. + // Since PMF_CONFIG_DIR is set to tests/content/core/config, if database.php doesn't exist there + // the test would pass. But it does exist. So we test via a different approach: + // We'll just verify the exception is thrown by temporarily renaming the file. + $dbFile = PMF_CONFIG_DIR . '/database.php'; + $tempFile = PMF_CONFIG_DIR . '/database.php.bak'; + + if (!file_exists($dbFile)) { + // If no database.php exists, the method should throw directly + $method->invoke($this->client, 'tenant_db'); + return; + } + + rename($dbFile, $tempFile); + try { + $method->invoke($this->client, 'tenant_db'); + } finally { + rename($tempFile, $dbFile); + } + } + + public function testCreateClientTablesWithDatabaseThrowsOnCreateTenantDatabaseFailure(): void + { + $this->assertFileExists( + PMF_CONFIG_DIR . '/database.php', + 'Test fixture tests/content/core/config/database.php is required.', + ); + + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $dbMock->method('query')->willReturnCallback(static function (string $query): mixed { + // collectSeedRows SELECT queries return a result + if (str_starts_with($query, 'SELECT * FROM')) { + return new \stdClass(); + } + // createTenantDatabase: SELECT 1 FROM pg_database returns a result + if (str_starts_with($query, 'SELECT 1 FROM pg_database')) { + return new \stdClass(); + } + // CREATE DATABASE fails + if (str_starts_with($query, 'CREATE DATABASE')) { + return false; + } + return true; + }); + $dbMock->method('fetchAll')->willReturn([]); + $dbMock->method('numRows')->willReturn(0); // database does not exist yet + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + + $loggerMock->expects($this->once())->method('error'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create tenant database "fail_db".'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('fail_db', TenantIsolationMode::DATABASE); + } + + public function testCreateClientTablesWithDatabaseThrowsOnConnectFailure(): void + { + $this->assertFileExists( + PMF_CONFIG_DIR . '/database.php', + 'Test fixture tests/content/core/config/database.php is required.', + ); + + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $dbMock->method('query')->willReturnCallback(static function (string $query): mixed { + if (str_starts_with($query, 'SELECT * FROM')) { + return new \stdClass(); + } + if (str_starts_with($query, 'SELECT 1 FROM pg_database')) { + return new \stdClass(); + } + return true; + }); + $dbMock->method('fetchAll')->willReturn([]); + $dbMock->method('numRows')->willReturn(0); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + $dbMock->method('connect')->willReturn(false); + $dbMock->method('error')->willReturn('Connection refused'); + + $loggerMock->expects($this->once())->method('error'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to connect to tenant database "connect_fail_db"'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('connect_fail_db', TenantIsolationMode::DATABASE); + } + + public function testCreateClientTablesWithDatabaseThrowsOnCreateTablesFailure(): void + { + $this->assertFileExists( + PMF_CONFIG_DIR . '/database.php', + 'Test fixture tests/content/core/config/database.php is required.', + ); + + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $queryCount = 0; + $dbMock->method('query')->willReturnCallback(static function (string $query) use (&$queryCount): mixed { + if (str_starts_with($query, 'SELECT * FROM')) { + return new \stdClass(); + } + if (str_starts_with($query, 'SELECT 1 FROM pg_database')) { + return new \stdClass(); + } + // Let createTenantDatabase's CREATE DATABASE succeed + if (str_starts_with($query, 'CREATE DATABASE')) { + return true; + } + // Fail on the first CREATE TABLE (from createTables/SchemaInstaller) + if (str_starts_with($query, 'CREATE TABLE')) { + return false; + } + return true; + }); + $dbMock->method('fetchAll')->willReturn([]); + $dbMock->method('numRows')->willReturn(0); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + $dbMock->method('connect')->willReturn(true); + + $loggerMock->expects($this->once())->method('error'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create tables in tenant database "tables_fail_db"'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('tables_fail_db', TenantIsolationMode::DATABASE); + } } From bd208842a42a204b3ac3e7afdfbf7ba83d3cdbd6 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 9 Feb 2026 08:52:16 +0100 Subject: [PATCH 9/9] fix: corrected review notes --- phpmyfaq/src/phpMyFAQ/Instance/Client.php | 91 +++++++++++++++++----- tests/phpMyFAQ/Instance/ClientTest.php | 95 ++++++++++++++++++++++- 2 files changed, 163 insertions(+), 23 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Client.php b/phpmyfaq/src/phpMyFAQ/Instance/Client.php index 3278691916..ac5674b095 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Client.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Client.php @@ -208,13 +208,21 @@ private function createClientTablesWithDatabase(string $databaseName): void ]); throw $exception; } finally { - $this->configuration->getDb()->connect( - $credentials['server'], - $credentials['user'], - $credentials['password'], - $sourceDatabase, - $credentials['port'], - ); + try { + $this->configuration->getDb()->connect( + $credentials['server'], + $credentials['user'], + $credentials['password'], + $sourceDatabase, + $credentials['port'], + ); + } catch (\Throwable $reconnectException) { + $this->configuration->getLogger()->error('Failed to reconnect to source database.', [ + 'message' => $reconnectException->getMessage(), + 'trace' => $reconnectException->getTraceAsString(), + 'database' => $sourceDatabase, + ]); + } } } @@ -237,34 +245,75 @@ private function copyBaseDataToSchema(string $schema): void if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql')) { $targetPrefix = sprintf('"%s".', $schema); - $this->configuration->getDb()->query(sprintf('SET search_path TO "%s"', $schema)); + $this->executeSchemaQuery( + sprintf('SET search_path TO "%s"', $schema), + 'SET search_path', + $targetPrefix, + $sourcePrefix, + ); } elseif (str_contains($dbType, 'sqlsrv') || str_contains($dbType, 'Sqlsrv')) { $targetPrefix = sprintf('[%s].', $schema); } - $this->configuration - ->getDb() - ->query(sprintf('INSERT INTO %sfaqconfig SELECT * FROM %sfaqconfig', $targetPrefix, $sourcePrefix)); + $this->executeSchemaQuery( + sprintf('INSERT INTO %sfaqconfig SELECT * FROM %sfaqconfig', $targetPrefix, $sourcePrefix), + 'INSERT faqconfig', + $targetPrefix, + $sourcePrefix, + ); - $this->configuration - ->getDb() - ->query(sprintf( + $this->executeSchemaQuery( + sprintf( "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'", $targetPrefix, $escapedClientUrl, - )); + ), + 'UPDATE faqconfig', + $targetPrefix, + $sourcePrefix, + ); - $this->configuration - ->getDb() - ->query(sprintf('INSERT INTO %sfaqright SELECT * FROM %sfaqright', $targetPrefix, $sourcePrefix)); + $this->executeSchemaQuery( + sprintf('INSERT INTO %sfaqright SELECT * FROM %sfaqright', $targetPrefix, $sourcePrefix), + 'INSERT faqright', + $targetPrefix, + $sourcePrefix, + ); - $this->configuration - ->getDb() - ->query(sprintf( + $this->executeSchemaQuery( + sprintf( 'INSERT INTO %sfaquser_right SELECT * FROM %sfaquser_right WHERE user_id = 1', $targetPrefix, $sourcePrefix, + ), + 'INSERT faquser_right', + $targetPrefix, + $sourcePrefix, + ); + } + + /** + * Executes a query during schema data copy and throws on failure. + * + * @throws Exception + */ + private function executeSchemaQuery( + string $query, + string $operation, + string $targetPrefix, + string $sourcePrefix, + ): void { + $result = $this->configuration->getDb()->query($query); + + if ($result === false) { + throw new Exception(sprintf( + 'Failed to %s (target: %s, source: %s): %s', + $operation, + $targetPrefix, + $sourcePrefix, + $this->configuration->getDb()->error(), )); + } } private function getDatabaseCredentials(): ?array diff --git a/tests/phpMyFAQ/Instance/ClientTest.php b/tests/phpMyFAQ/Instance/ClientTest.php index 125d720598..8bf9ba90fe 100644 --- a/tests/phpMyFAQ/Instance/ClientTest.php +++ b/tests/phpMyFAQ/Instance/ClientTest.php @@ -153,6 +153,40 @@ public function testCreateClientDatabaseWithSchemaMode(): void ); } + public function testCreateClientDatabaseWithSchemaModeThrowsOnQueryFailure(): void + { + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + // SET search_path succeeds, INSERT INTO faqconfig fails + $dbMock->method('query')->willReturnCallback(static function (string $query): mixed { + if (str_contains($query, 'SET search_path')) { + return true; + } + if (str_contains($query, 'CREATE')) { + return true; + } + // First INSERT fails + return false; + }); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + $dbMock->method('error')->willReturn('relation "faqconfig" does not exist'); + + $loggerMock->expects($this->once())->method('error'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to INSERT faqconfig'); + + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('fail_schema', TenantIsolationMode::SCHEMA); + } + public function testCreateClientDatabaseWithDatabaseMode(): void { if (!file_exists(PMF_CONFIG_DIR . '/database.php')) { @@ -441,8 +475,7 @@ public function testCreateClientTablesWithDatabaseThrowsOnCreateTablesFailure(): $this->configuration->method('getDb')->willReturn($dbMock); $this->configuration->method('getLogger')->willReturn($loggerMock); - $queryCount = 0; - $dbMock->method('query')->willReturnCallback(static function (string $query) use (&$queryCount): mixed { + $dbMock->method('query')->willReturnCallback(static function (string $query): mixed { if (str_starts_with($query, 'SELECT * FROM')) { return new \stdClass(); } @@ -472,4 +505,62 @@ public function testCreateClientTablesWithDatabaseThrowsOnCreateTablesFailure(): $this->client->setClientUrl('https://tenant.example.com'); $this->client->createClientDatabase('tables_fail_db', TenantIsolationMode::DATABASE); } + + public function testReconnectFailureIsLoggedWithoutSwallowingOriginalException(): void + { + $this->assertFileExists( + PMF_CONFIG_DIR . '/database.php', + 'Test fixture tests/content/core/config/database.php is required.', + ); + + Database::factory('pdo_pgsql'); + Database::setTablePrefix(''); + + $dbMock = $this->createMock(DatabaseDriver::class); + $loggerMock = $this->createMock(Logger::class); + + $this->configuration->method('getDb')->willReturn($dbMock); + $this->configuration->method('getLogger')->willReturn($loggerMock); + + $dbMock->method('query')->willReturnCallback(static function (string $query): mixed { + if (str_starts_with($query, 'SELECT * FROM')) { + return new \stdClass(); + } + if (str_starts_with($query, 'SELECT 1 FROM pg_database')) { + return new \stdClass(); + } + // CREATE DATABASE fails to trigger the original exception + if (str_starts_with($query, 'CREATE DATABASE')) { + return false; + } + return true; + }); + $dbMock->method('fetchAll')->willReturn([]); + $dbMock->method('numRows')->willReturn(0); + $dbMock->method('escape')->willReturnCallback(static fn(string $value): string => $value); + + // Reconnect throws an exception + $dbMock->method('connect')->willThrowException(new \RuntimeException('Connection lost')); + + // Expect two logger->error calls: one for the original failure, one for the reconnect failure + $logMessages = []; + $loggerMock + ->expects($this->exactly(2)) + ->method('error') + ->willReturnCallback(static function (string $message, array $context) use (&$logMessages): void { + $logMessages[] = $message; + }); + + try { + $this->client->setClientUrl('https://tenant.example.com'); + $this->client->createClientDatabase('reconnect_fail_db', TenantIsolationMode::DATABASE); + $this->fail('Expected Exception was not thrown.'); + } catch (Exception $exception) { + // The original exception is preserved, not the reconnect one + $this->assertStringContainsString('Failed to create tenant database "reconnect_fail_db"', $exception->getMessage()); + } + + $this->assertContains('Failed to create tenant database tables.', $logMessages); + $this->assertContains('Failed to reconnect to source database.', $logMessages); + } }