diff --git a/resources/config/.gitignore.template b/resources/config/.gitignore.template new file mode 100644 index 0000000..48d4d7b --- /dev/null +++ b/resources/config/.gitignore.template @@ -0,0 +1,23 @@ +# Encryption keys - NEVER commit these +/master.key +/secrets/*.key +*.key + +# Local environment overrides +.env +.env.local +.env.*.local + +# Environment-specific configuration (if not using encrypted secrets) +/environments/*.local.yaml + +# Temporary files +*.tmp +*.bak +*~ + +# IDE settings (optional, uncomment if needed) +#.idea/ +#.vscode/ +#*.swp +#*.swo \ No newline at end of file diff --git a/resources/config/neuron.yaml b/resources/config/neuron.yaml index cf4fb9c..c0ac1f6 100644 --- a/resources/config/neuron.yaml +++ b/resources/config/neuron.yaml @@ -47,6 +47,7 @@ theme: # Security Configuration security: + enabled: true # Dummy value to prevent null errors in PHP 8.4 # Content Security Policy (CSP) # Customize this based on your application's needs # content_security_policy: "default-src 'self'; script-src 'self' 'unsafe-inline';" diff --git a/resources/config/neuron.yaml.example b/resources/config/neuron.yaml.example index 414384e..333020d 100644 --- a/resources/config/neuron.yaml.example +++ b/resources/config/neuron.yaml.example @@ -98,6 +98,27 @@ cloudinary: # All three databases (SQLite, MySQL, PostgreSQL) are fully supported # with identical behavior - choose based on your deployment needs +# === NEW: URL-Based Configuration === +# You can now configure your database using a single URL string +# This is especially useful for cloud platforms and environment-based configs +# +# Format: adapter://user:pass@host:port/database?options +# +# Examples: +# database: +# url: mysql://myuser:mypass@localhost:3306/neuron_cms?charset=utf8mb4 +# url: postgresql://postgres:secret@db.example.com:5432/myapp +# url: sqlite:///storage/database.sqlite3 +# url: sqlite::memory: # For in-memory database +# +# Environment variable (takes precedence over config file): +# DATABASE_URL=mysql://user:pass@host:3306/dbname +# +# You can also mix URL with individual overrides: +# database: +# url: mysql://user:pass@host:3306/dbname +# charset: latin1 # Override the charset from URL + # Option 1: SQLite (Recommended for development and small-to-medium sites) # - Zero configuration required # - Single file database @@ -106,6 +127,8 @@ cloudinary: database: adapter: sqlite name: storage/database.sqlite3 + # Or use URL format: + # url: sqlite:///storage/database.sqlite3 # Option 2: MySQL (Recommended for large-scale production) # - Best for high-traffic websites @@ -119,6 +142,8 @@ database: # user: root # pass: secret # charset: utf8mb4 # UTF8MB4 recommended for full Unicode support +# # Or use URL format: +# # url: mysql://root:secret@localhost:3306/neuron_cms?charset=utf8mb4 # Option 3: PostgreSQL (Enterprise features and complex queries) # - Advanced query capabilities @@ -131,6 +156,8 @@ database: # name: neuron_cms # user: postgres # pass: secret +# # Or use URL format: +# # url: postgresql://postgres:secret@localhost:5432/neuron_cms # Note: All databases use UTC timezone and have foreign key constraints enforced # Timestamp updates (created_at, updated_at) work identically across all platforms diff --git a/resources/views/auth/password_reset/forgot-password.php b/resources/views/auth/password_reset/forgot-password.php index f6ae598..722ec32 100644 --- a/resources/views/auth/password_reset/forgot-password.php +++ b/resources/views/auth/password_reset/forgot-password.php @@ -6,7 +6,7 @@

Forgot Password

Enter your email address and we'll send you a password reset link.

-
+ diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 1c71939..b568df4 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -4,6 +4,8 @@ use Neuron\Data\Objects\Version; use Neuron\Data\Settings\Source\Yaml; use Neuron\Data\Settings\SettingManager; +use Neuron\Data\Settings\SettingManagerFactory; +use Neuron\Data\Factories; use Neuron\Mvc\Application; use Neuron\Mvc\IMvcApplication; use Neuron\Orm\Model; @@ -42,30 +44,58 @@ function boot( string $configPath ) : Application { + // Initialize settings variable to avoid undefined variable error + $settings = null; + $container = null; + // Build and register the DI container BEFORE MVC boot // This ensures the container is available to initializers during boot try { - // Determine which configuration file to load based on environment + // Determine environment // Check APP_ENV environment variable (testing, development, production) $environment = getenv( 'APP_ENV' ) ?: 'production'; - $configFile = match( $environment ) { - 'testing' => 'neuron.testing.yaml', - 'development' => 'neuron.yaml', - 'production' => 'neuron.yaml', - default => 'neuron.yaml' - }; - // Fallback to neuron.yaml if environment-specific file doesn't exist - $configFilePath = "$configPath/$configFile"; - if( !file_exists( $configFilePath ) ) - { - $configFilePath = "$configPath/neuron.yaml"; - } + // Use SettingManagerFactory for comprehensive configuration loading + // This will automatically load: + // 1. neuron.yaml (base configuration) + // 2. Environment-specific config if exists + // 3. Encrypted secrets from config/secrets.yml.enc (if exists) + // 4. Environment-specific encrypted secrets if exist + // 5. Environment variables (highest priority) + $sources = [ + [ + 'type' => 'yaml', + 'path' => match( $environment ) { + 'testing' => file_exists( "$configPath/neuron.testing.yaml" ) + ? "$configPath/neuron.testing.yaml" + : "$configPath/neuron.yaml", + default => "$configPath/neuron.yaml" + }, + 'name' => 'config' + ], + // Only include main secrets if both the encrypted file and key exist + file_exists( "$configPath/secrets.yml.enc" ) && file_exists( "$configPath/master.key" ) ? [ + 'type' => 'encrypted', + 'path' => "$configPath/secrets.yml.enc", + 'key' => "$configPath/master.key", + 'name' => 'secrets' + ] : null, + // Only include environment-specific secrets if both files exist + file_exists( "$configPath/secrets/$environment.yml.enc" ) && file_exists( "$configPath/secrets/$environment.key" ) ? [ + 'type' => 'encrypted', + 'path' => "$configPath/secrets/$environment.yml.enc", + 'key' => "$configPath/secrets/$environment.key", + 'name' => "secrets:$environment" + ] : null, + [ + 'type' => 'env', + 'name' => 'environment' + ] + ]; - // Create SettingManager from config file - $yaml = new Yaml( $configFilePath ); - $settings = new SettingManager( $yaml ); + // Filter out null entries (non-existent encrypted sources) + $settings = SettingManagerFactory::createCustom( array_filter( $sources ) ); // Build container (automatically registers in Registry) $container = Container::build( $settings ); @@ -73,10 +103,18 @@ function boot( string $configPath ) : Application catch( \Exception $e ) { \Neuron\Log\Log::error( 'Container initialization failed: ' . $e->getMessage() ); + + // Create a fallback settings with minimal configuration + // This allows the application to start even if advanced configuration fails + $yaml = new Yaml( "$configPath/neuron.yaml" ); + $settings = new SettingManager( $yaml ); } - // Boot MVC application (initializers can now access Container from Registry) - $app = \Neuron\Mvc\boot( $configPath ); + // Create MVC application with our configured SettingManager + // (Don't use MVC boot as it would create its own SettingManager without secrets) + $basePath = $settings->get( 'system', 'base_path' ) ?: getenv( 'SYSTEM_BASE_PATH' ) ?: '.'; + $version = \Neuron\Data\Factories\Version::fromFile( "$basePath/.version.json" ); + $app = new Application( $version->getAsString(), $settings ); // Update container with Application instance and set on app if( isset( $container ) ) diff --git a/src/Cms/Cli/Commands/Install/InstallCommand.php b/src/Cms/Cli/Commands/Install/InstallCommand.php index 836809c..a819cf3 100644 --- a/src/Cms/Cli/Commands/Install/InstallCommand.php +++ b/src/Cms/Cli/Commands/Install/InstallCommand.php @@ -12,9 +12,11 @@ use Neuron\Cms\Auth\PasswordHasher; use Neuron\Data\Settings\SettingManager; use Neuron\Data\Settings\Source\Yaml; +use Neuron\Data\Settings\SecretManager; use Neuron\Patterns\Registry; use Neuron\Cms\Enums\UserRole; use Neuron\Cms\Enums\UserStatus; +use Symfony\Component\Yaml\Yaml as YamlParser; /** * Install the CMS admin UI into the project @@ -63,6 +65,11 @@ public function configure(): void 'f', 'Force reinstall without prompts (skips database setup, migrations, and user creation)' ); + $this->addOption( + 'use-secrets', + 's', + 'Use encrypted credentials for sensitive configuration (recommended for production)' + ); } /** @@ -71,6 +78,7 @@ public function configure(): void public function execute( array $parameters = [] ): int { $force = $this->hasOption( 'force' ) || $this->hasOption( 'f' ); + $useSecrets = $this->hasOption( 'use-secrets' ) || $this->hasOption( 's' ); $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); $this->output->writeln( "║ Neuron CMS - Installation ║" ); @@ -123,7 +131,16 @@ public function execute( array $parameters = [] ): int { $this->output->writeln( $message ); - if( !$this->$method() ) + // Pass useSecrets flag to setupDatabase + if( $method === 'setupDatabase' ) + { + if( !$this->$method( $useSecrets ) ) + { + $this->output->error( "Installation failed!" ); + return 1; + } + } + elseif( !$this->$method() ) { $this->output->error( "Installation failed!" ); return 1; @@ -317,11 +334,44 @@ protected function publishInitializers(): bool return $this->copyDirectory( $initializerSource, $initializerDest ); } + /** + * Copy .gitignore template to config directory + */ + protected function copyGitignoreTemplate(): bool + { + $sourceFile = $this->_componentPath . '/resources/config/.gitignore.template'; + $destFile = $this->_projectPath . '/config/.gitignore'; + + // Skip if already exists + if( file_exists( $destFile ) ) + { + return true; + } + + // Check if source exists + if( !file_exists( $sourceFile ) ) + { + // Not critical, just skip + return true; + } + + // Copy the template + if( copy( $sourceFile, $destFile ) ) + { + $this->_messages[] = "Created: config/.gitignore"; + } + + return true; + } + /** * Create route configuration */ protected function createRouteConfig(): bool { + // Copy .gitignore template first + $this->copyGitignoreTemplate(); + $routeFile = $this->_projectPath . '/config/routes.yaml'; $resourceFile = $this->_componentPath . '/resources/config/routes.yaml'; @@ -489,35 +539,45 @@ protected function copyDirectory( string $source, string $dest ): bool /** * Setup database configuration */ - protected function setupDatabase(): bool + protected function setupDatabase( bool $useSecrets = false ): bool { $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); $this->output->writeln( "║ Database Configuration ║" ); $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); - $choice = $this->choice( - "Select database adapter:", - [ - 'sqlite' => 'SQLite - Recommended for development (zero config, single file)', - 'mysql' => 'MySQL - Recommended for production (proven scalability)', - 'pgsql' => 'PostgreSQL - Enterprise features (advanced querying)' - ], - 'sqlite' - ); - - $this->output->writeln( "\nAll databases support:" ); - $this->output->writeln( " ✓ Foreign key constraints with cascade deletes" ); - $this->output->writeln( " ✓ Automatic timestamp management" ); - $this->output->writeln( " ✓ Full ACID transaction support" ); - $this->output->writeln( "" ); + // First, ask if they want to use URL configuration + $useUrl = $this->confirm( "Would you like to configure the database using a URL? (e.g., from cloud provider)", false ); - $config = match( $choice ) + if( $useUrl ) { - 'sqlite' => $this->configureSqlite(), - 'mysql' => $this->configureMysql(), - 'pgsql' => $this->configurePostgresql(), - default => $this->configureSqlite() - }; + $config = $this->configureDatabaseUrl(); + } + else + { + $choice = $this->choice( + "Select database adapter:", + [ + 'sqlite' => 'SQLite - Recommended for development (zero config, single file)', + 'mysql' => 'MySQL - Recommended for production (proven scalability)', + 'pgsql' => 'PostgreSQL - Enterprise features (advanced querying)' + ], + 'sqlite' + ); + + $this->output->writeln( "\nAll databases support:" ); + $this->output->writeln( " ✓ Foreign key constraints with cascade deletes" ); + $this->output->writeln( " ✓ Automatic timestamp management" ); + $this->output->writeln( " ✓ Full ACID transaction support" ); + $this->output->writeln( "" ); + + $config = match( $choice ) + { + 'sqlite' => $this->configureSqlite(), + 'mysql' => $this->configureMysql(), + 'pgsql' => $this->configurePostgresql(), + default => $this->configureSqlite() + }; + } if( !$config ) { @@ -539,7 +599,7 @@ protected function setupDatabase(): bool $emailConfig = $this->configureEmail(); // Merge and save complete configuration - return $this->saveCompleteConfig( $config, $appConfig, $cloudinaryConfig, $emailConfig ); + return $this->saveCompleteConfig( $config, $appConfig, $cloudinaryConfig, $emailConfig, $useSecrets ); } /** @@ -809,10 +869,10 @@ protected function configureEmail(): array /** * Save complete configuration with all required sections */ - protected function saveCompleteConfig( array $databaseConfig, array $appConfig, array $cloudinaryConfig = [], array $emailConfig = [] ): bool + protected function saveCompleteConfig( array $databaseConfig, array $appConfig, array $cloudinaryConfig = [], array $emailConfig = [], bool $useSecrets = false ): bool { - // Build complete configuration - $config = [ + // Build base configuration (non-sensitive) + $publicConfig = [ 'logging' => [ 'destination' => '\\Neuron\\Log\\Destination\\File', 'format' => '\\Neuron\\Log\\Format\\PlainText', @@ -852,23 +912,203 @@ protected function saveCompleteConfig( array $databaseConfig, array $appConfig, ] ]; - // Merge database configuration - $config = array_merge( $config, $databaseConfig ); + // Initialize secrets array + $secretsConfig = []; - // Merge Cloudinary configuration if provided - if( !empty( $cloudinaryConfig ) ) + // Handle database configuration + if( isset( $databaseConfig['database'] ) ) { - $config = array_merge( $config, $cloudinaryConfig ); + $db = $databaseConfig['database']; + + if( $useSecrets ) + { + // Check if using URL-based configuration + if( isset( $db['url'] ) ) + { + // With URL config, keep entire URL in secrets for security + $secretsConfig['database'] = [ 'url' => $db['url'] ]; + + // Don't set any database config in public file when using URL in secrets + // The URL contains all needed configuration + } + else + { + // Traditional configuration with individual parameters + // Keep non-sensitive database info in public config + $publicConfig['database'] = [ + 'adapter' => $db['adapter'] + ]; + + // SQLite path is not sensitive, keep it public + if( $db['adapter'] === 'sqlite' ) + { + $publicConfig['database']['name'] = $db['name']; + } + else + { + // For MySQL/PostgreSQL, put credentials in secrets + $publicConfig['database']['host'] = $db['host'] ?? 'localhost'; + $publicConfig['database']['port'] = $db['port'] ?? null; + $publicConfig['database']['name'] = $db['name'] ?? null; + + // Put username and password in secrets + if( isset( $db['user'] ) || isset( $db['pass'] ) ) + { + $secretsConfig['database'] = []; + if( isset( $db['user'] ) ) $secretsConfig['database']['user'] = $db['user']; + if( isset( $db['pass'] ) ) $secretsConfig['database']['pass'] = $db['pass']; + } + } + } + } + else + { + // Not using secrets, put everything in public config + $publicConfig = array_merge( $publicConfig, $databaseConfig ); + } + } + + // Handle Cloudinary configuration + if( !empty( $cloudinaryConfig['cloudinary'] ) ) + { + if( $useSecrets ) + { + // All Cloudinary config is sensitive (contains API keys) + $secretsConfig['cloudinary'] = $cloudinaryConfig['cloudinary']; + } + else + { + $publicConfig = array_merge( $publicConfig, $cloudinaryConfig ); + } } - // Merge Email configuration if provided - if( !empty( $emailConfig ) ) + // Handle Email configuration + if( !empty( $emailConfig['email'] ) ) { - $config = array_merge( $config, $emailConfig ); + $email = $emailConfig['email']; + + if( $useSecrets ) + { + // Keep non-sensitive email config public + $publicConfig['email'] = [ + 'driver' => $email['driver'] ?? 'mail', + 'from_address' => $email['from_address'] ?? 'noreply@example.com', + 'from_name' => $email['from_name'] ?? 'Neuron CMS' + ]; + + if( isset( $email['test_mode'] ) ) + { + $publicConfig['email']['test_mode'] = $email['test_mode']; + } + + // SMTP-specific settings + if( isset( $email['host'] ) ) + { + $publicConfig['email']['host'] = $email['host']; + $publicConfig['email']['port'] = $email['port'] ?? 587; + $publicConfig['email']['encryption'] = $email['encryption'] ?? 'tls'; + } + + // Put credentials in secrets + if( isset( $email['username'] ) || isset( $email['password'] ) ) + { + $secretsConfig['email'] = []; + if( isset( $email['username'] ) ) $secretsConfig['email']['username'] = $email['username']; + if( isset( $email['password'] ) ) $secretsConfig['email']['password'] = $email['password']; + } + } + else + { + $publicConfig = array_merge( $publicConfig, $emailConfig ); + } } // Save configuration - return $this->saveConfig( $config ); + if( $useSecrets && !empty( $secretsConfig ) ) + { + // Save with encrypted secrets + return $this->saveConfigWithSecrets( $publicConfig, $secretsConfig ); + } + else + { + // Save everything in one file + return $this->saveConfig( $publicConfig ); + } + } + + /** + * Configure database using URL + */ + protected function configureDatabaseUrl(): array + { + $this->output->writeln( "\n--- Database URL Configuration ---\n" ); + $this->output->writeln( "Enter your database URL in one of these formats:" ); + $this->output->writeln( " • MySQL: mysql://user:pass@host:3306/dbname?charset=utf8mb4" ); + $this->output->writeln( " • PostgreSQL: postgresql://user:pass@host:5432/dbname" ); + $this->output->writeln( " • SQLite: sqlite:///path/to/database.sqlite3" ); + $this->output->writeln( " • SQLite: sqlite::memory: (for in-memory database)" ); + $this->output->writeln( "" ); + $this->output->writeln( "Cloud platforms often provide DATABASE_URL automatically." ); + $this->output->writeln( "" ); + + $url = $this->prompt( "Database URL" ); + + if( !$url ) + { + $this->output->error( "Database URL is required!" ); + return []; + } + + // SQLite URLs don't parse with parse_url, handle them specially + if( str_starts_with( $url, 'sqlite:' ) ) + { + $adapter = 'SQLite'; + } + else + { + // Validate non-SQLite URLs by parsing them + $scheme = parse_url( $url, PHP_URL_SCHEME ); + + // parse_url returns false or null for invalid URLs + if( !$scheme ) + { + $this->output->error( "Invalid database URL format. Please provide a valid URL." ); + return []; + } + + // Check if the scheme is supported + $adapter = match( $scheme ) + { + 'mysql' => 'MySQL', + 'postgresql', 'postgres', 'pgsql' => 'PostgreSQL', + default => null + }; + + if( !$adapter ) + { + $this->output->error( "Unsupported database scheme: '$scheme'. Supported: mysql, postgresql, postgres, pgsql, sqlite" ); + return []; + } + } + + // For non-SQLite databases, validate that we have required components + if( $adapter !== 'SQLite' ) + { + $parsed = parse_url( $url ); + if( !isset( $parsed['host'] ) || !isset( $parsed['path'] ) || $parsed['path'] === '/' ) + { + $this->output->error( "Invalid database URL. MySQL/PostgreSQL URLs must include host and database name." ); + return []; + } + } + + $this->_messages[] = "Database: $adapter (configured via URL)"; + + return [ + 'database' => [ + 'url' => $url + ] + ]; } /** @@ -1043,6 +1283,148 @@ protected function saveConfig( array $config ): bool return true; } + /** + * Save configuration with encrypted secrets + */ + protected function saveConfigWithSecrets( array $publicConfig, array $secretsConfig ): bool + { + $configDir = $this->_projectPath . '/config'; + $keyPath = $configDir . '/master.key'; + $credentialsPath = $configDir . '/secrets.yml.enc'; + $configFile = $configDir . '/neuron.yaml'; + + // Generate master key if it doesn't exist + if( !file_exists( $keyPath ) ) + { + $this->output->writeln( "\nGenerating encryption key..." ); + + $secretManager = new SecretManager(); + $key = $secretManager->generateKey( $keyPath ); + + $this->output->success( "Generated master key: {$keyPath}" ); + $this->output->warning( "IMPORTANT: Add this file to .gitignore - NEVER commit it!" ); + $this->output->info( "Key value (save this securely): {$key}" ); + $this->output->writeln( "" ); + + $this->_messages[] = "Generated encryption key: config/master.key"; + } + + // Save encrypted secrets + if( !empty( $secretsConfig ) ) + { + $this->output->writeln( "Encrypting sensitive configuration..." ); + + $secretManager = new SecretManager(); + $yamlContent = YamlParser::dump( $secretsConfig, 4, 2 ); + + try + { + $secretManager->encrypt( $yamlContent, $credentialsPath, $keyPath ); + $this->_messages[] = "Created encrypted secrets: config/secrets.yml.enc"; + } + catch( \Exception $e ) + { + $this->output->error( "Failed to encrypt secrets: " . $e->getMessage() ); + return false; + } + } + + // Save public configuration + $this->output->writeln( "Saving public configuration..." ); + + // Convert to YAML + $yamlContent = $this->arrayToYaml( $publicConfig ); + + if( file_put_contents( $configFile, $yamlContent ) === false ) + { + $this->output->error( "Failed to save configuration file!" ); + return false; + } + + $this->_messages[] = "Created public configuration: config/neuron.yaml"; + + // Update or create .gitignore + $this->updateGitignore(); + + // Show security instructions + $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); + $this->output->writeln( "║ Security Instructions ║" ); + $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); + $this->output->writeln( "1. The master key has been generated at: config/master.key" ); + $this->output->writeln( "2. This key is required to decrypt your secrets" ); + $this->output->writeln( "3. NEVER commit this key to version control" ); + $this->output->writeln( "4. Share this key securely with your team" ); + $this->output->writeln( "5. You can also set it as an environment variable:" ); + $this->output->writeln( " export NEURON_MASTER_KEY=" ); + $this->output->writeln( "" ); + $this->output->writeln( "To edit secrets later, run: neuron secrets:edit" ); + $this->output->writeln( "To view secrets, run: neuron secrets:show" ); + $this->output->writeln( "" ); + + return true; + } + + /** + * Update .gitignore to exclude key files + */ + protected function updateGitignore(): bool + { + $gitignorePath = $this->_projectPath . '/.gitignore'; + $configGitignorePath = $this->_projectPath . '/config/.gitignore'; + + // Patterns to add + $patterns = [ + '# Encryption keys - NEVER commit these', + '/config/master.key', + '/config/secrets/*.key', + '*.key' + ]; + + // Update root .gitignore if it exists + if( file_exists( $gitignorePath ) ) + { + $content = file_get_contents( $gitignorePath ); + $hasKeyPatterns = false; + + foreach( $patterns as $pattern ) + { + if( strpos( $content, $pattern ) !== false ) + { + $hasKeyPatterns = true; + break; + } + } + + if( !$hasKeyPatterns ) + { + // Add patterns + $content .= "\n" . implode( "\n", $patterns ) . "\n"; + file_put_contents( $gitignorePath, $content ); + $this->_messages[] = "Updated .gitignore to exclude key files"; + } + } + + // Create config/.gitignore + if( !file_exists( $configGitignorePath ) ) + { + $configGitignoreContent = implode( "\n", [ + '# Encryption keys - NEVER commit these', + '/master.key', + '/secrets/*.key', + '*.key', + '', + '# Local environment overrides', + '.env', + '.env.local' + ] ); + + file_put_contents( $configGitignorePath, $configGitignoreContent ); + $this->_messages[] = "Created config/.gitignore"; + } + + return true; + } + /** * Convert array to YAML format (simple implementation) */ diff --git a/src/Cms/Database/ConnectionFactory.php b/src/Cms/Database/ConnectionFactory.php index b60e5fd..ee2334e 100644 --- a/src/Cms/Database/ConnectionFactory.php +++ b/src/Cms/Database/ConnectionFactory.php @@ -38,12 +38,27 @@ public static function createFromSettings( SettingManager $settings ): PDO /** * Create a PDO connection from configuration array * + * Supports both URL-based configuration and individual parameters. + * If 'url' is provided, it will be parsed and merged with individual parameters. + * Individual parameters take precedence over URL-parsed values. + * * @param array $config Database configuration * @return PDO - * @throws Exception if adapter is unsupported + * @throws Exception if adapter is unsupported or configuration is invalid */ public static function createFromConfig( array $config ): PDO { + // If URL is provided, parse it and merge with config + if( !empty( $config['url'] ) ) + { + $urlConfig = self::parseUrl( $config['url'] ); + // Merge URL config with provided config (provided config takes precedence) + $config = array_merge( $urlConfig, array_filter( $config, function( $value, $key ) { + // Keep non-null values that aren't the URL itself + return $key !== 'url' && $value !== null; + }, ARRAY_FILTER_USE_BOTH ) ); + } + $adapter = $config['adapter'] ?? 'sqlite'; if( empty( $config['name'] ) ) @@ -91,6 +106,119 @@ public static function createFromConfig( array $config ): PDO return $pdo; } + /** + * Parse a database URL into configuration array + * + * Supports common database URL formats: + * - postgresql://user:pass@host:5432/dbname?option=value + * - mysql://user:pass@host:3306/dbname?charset=utf8mb4 + * - sqlite:///path/to/database.db or sqlite::memory: + * + * @param string $url Database URL + * @return array Parsed configuration + * @throws Exception if URL is malformed + */ + private static function parseUrl( string $url ): array + { + // Handle SQLite's special cases first + if( str_starts_with( $url, 'sqlite:' ) ) + { + // Remove sqlite: prefix + $path = substr( $url, 7 ); + + // Handle special :memory: database + if( $path === ':memory:' ) + { + return [ + 'adapter' => 'sqlite', + 'name' => ':memory:' + ]; + } + + // Handle absolute paths (sqlite:///path or sqlite://path) + // Check triple slash first (absolute path) + if( str_starts_with( $path, '///' ) ) + { + // Three slashes means absolute path + $path = substr( $path, 2 ); + } + elseif( str_starts_with( $path, '//' ) ) + { + // Two slashes, could be relative or network path + $path = substr( $path, 2 ); + } + + return [ + 'adapter' => 'sqlite', + 'name' => $path + ]; + } + + // Parse URL for other databases + $parsed = parse_url( $url ); + + if( $parsed === false || !isset( $parsed['scheme'] ) ) + { + throw new Exception( "Malformed database URL: $url" ); + } + + // Map URL scheme to adapter + $adapter = match( $parsed['scheme'] ) + { + 'mysql' => 'mysql', + 'postgresql', 'postgres', 'pgsql' => 'pgsql', + default => throw new Exception( "Unsupported database scheme: {$parsed['scheme']}" ) + }; + + $config = [ 'adapter' => $adapter ]; + + // Extract host, port, user, password + if( isset( $parsed['host'] ) ) + { + $config['host'] = $parsed['host']; + } + + if( isset( $parsed['port'] ) ) + { + $config['port'] = $parsed['port']; + } + + if( isset( $parsed['user'] ) ) + { + // Decode URL-encoded characters (e.g., %40 for @) + $config['user'] = rawurldecode( $parsed['user'] ); + } + + if( isset( $parsed['pass'] ) ) + { + // Decode URL-encoded characters (e.g., %40 for @) + $config['pass'] = rawurldecode( $parsed['pass'] ); + } + + // Extract database name from path + if( isset( $parsed['path'] ) && $parsed['path'] !== '/' ) + { + // Remove leading slash + $config['name'] = ltrim( $parsed['path'], '/' ); + } + + // Parse query parameters for additional options + if( isset( $parsed['query'] ) ) + { + parse_str( $parsed['query'], $queryParams ); + + // Common query parameters + if( isset( $queryParams['charset'] ) ) + { + $config['charset'] = $queryParams['charset']; + } + + // You can add more query parameter mappings here as needed + } + + return $config; + } + /** * Initialize database-specific settings * diff --git a/tests/Unit/Cms/Cli/Commands/Install/DatabaseUrlValidationTest.php b/tests/Unit/Cms/Cli/Commands/Install/DatabaseUrlValidationTest.php new file mode 100644 index 0000000..bb5fba7 --- /dev/null +++ b/tests/Unit/Cms/Cli/Commands/Install/DatabaseUrlValidationTest.php @@ -0,0 +1,161 @@ +assertNotFalse( parse_url( 'mysql://user:pass@host/db', PHP_URL_SCHEME ) ); + $this->assertEquals( 'mysql', parse_url( 'mysql://user:pass@host/db', PHP_URL_SCHEME ) ); + + // Invalid URLs return false or null + $result = parse_url( 'not-a-url', PHP_URL_SCHEME ); + $this->assertTrue( $result === false || $result === null, "not-a-url should not parse" ); + + $this->assertNull( parse_url( ':::invalid:::', PHP_URL_SCHEME ) ); + $this->assertEmpty( parse_url( '', PHP_URL_SCHEME ) ); // Empty string returns empty result + + // URLs with unsupported schemes still parse + $this->assertEquals( 'mongodb', parse_url( 'mongodb://localhost/db', PHP_URL_SCHEME ) ); + $this->assertEquals( 'ftp', parse_url( 'ftp://example.com/file', PHP_URL_SCHEME ) ); + } + + /** + * Test validation logic for database URLs + */ + public function testDatabaseUrlValidation(): void + { + // Valid non-SQLite database URLs (these parse with parse_url) + $validStandardUrls = [ + 'mysql://user:pass@localhost:3306/dbname', + 'postgresql://user:pass@localhost:5432/dbname', + 'postgres://user@localhost/dbname', + 'pgsql://localhost/dbname', + ]; + + foreach( $validStandardUrls as $url ) + { + $scheme = parse_url( $url, PHP_URL_SCHEME ); + $this->assertNotFalse( $scheme, "URL should parse: $url" ); + + // Check if scheme is supported + $supported = in_array( $scheme, [ 'mysql', 'postgresql', 'postgres', 'pgsql' ] ); + $this->assertTrue( $supported, "Scheme should be supported: $scheme" ); + } + + // SQLite URLs have mixed parsing behavior + // Triple-slash doesn't parse + $this->assertFalse( parse_url( 'sqlite:///path/to/database.db' ) ); + + // Single slash and :memory: do parse + $this->assertNotFalse( parse_url( 'sqlite:/path/to/database.db' ) ); + $this->assertNotFalse( parse_url( 'sqlite::memory:' ) ); + + // All are valid SQLite URLs because they start with 'sqlite:' + $validSqliteUrls = [ + 'sqlite:///path/to/database.db', + 'sqlite:/path/to/database.db', + 'sqlite::memory:' + ]; + + foreach( $validSqliteUrls as $url ) + { + $this->assertTrue( str_starts_with( $url, 'sqlite:' ), "Should be SQLite URL: $url" ); + } + + // Invalid URLs + $invalidUrls = [ + 'not-a-url', + '', + 'just-text', + 'http://not-a-database', + 'mongodb://localhost/db', // Unsupported scheme + 'redis://localhost:6379', // Unsupported scheme + ]; + + foreach( $invalidUrls as $url ) + { + $scheme = parse_url( $url, PHP_URL_SCHEME ); + + if( $scheme === false || $scheme === null ) + { + // Invalid URL format - should be rejected + $this->assertTrue( true, "Invalid URL correctly identified: $url" ); + } + else + { + // Has a scheme but should be unsupported for databases + $supported = in_array( $scheme, [ 'mysql', 'postgresql', 'postgres', 'pgsql', 'sqlite' ] ); + $this->assertFalse( $supported, "Scheme should be unsupported: $scheme from $url" ); + } + } + } + + /** + * Test that MySQL/PostgreSQL URLs require host and database + */ + public function testNonSqliteUrlRequirements(): void + { + // These should be considered incomplete/invalid for MySQL/PostgreSQL + $incompleteUrls = [ + 'mysql://localhost', // No database name + 'mysql://localhost/', // Empty database name + 'postgresql:///', // No host or database + 'pgsql://:5432/dbname' // No host + ]; + + foreach( $incompleteUrls as $url ) + { + $parsed = parse_url( $url ); + $scheme = $parsed['scheme'] ?? null; + + if( in_array( $scheme, [ 'mysql', 'postgresql', 'postgres', 'pgsql' ] ) ) + { + // Check for required components + $hasHost = isset( $parsed['host'] ) && $parsed['host'] !== ''; + $hasDb = isset( $parsed['path'] ) && $parsed['path'] !== '/' && $parsed['path'] !== ''; + + $isValid = $hasHost && $hasDb; + $this->assertFalse( $isValid, "URL should be invalid (missing host/db): $url" ); + } + } + + // These should be valid + $validNonSqliteUrls = [ + 'mysql://localhost/mydb', + 'postgresql://host:5432/database', + ]; + + foreach( $validNonSqliteUrls as $url ) + { + $parsed = parse_url( $url ); + + $hasHost = isset( $parsed['host'] ) && $parsed['host'] !== ''; + $hasDb = isset( $parsed['path'] ) && $parsed['path'] !== '/' && $parsed['path'] !== ''; + + $this->assertTrue( $hasHost && $hasDb, "URL should be valid: $url" ); + } + + // SQLite URLs are special and don't need standard URL validation + $validSqliteUrls = [ + 'sqlite:///path/to/db.sqlite', + 'sqlite::memory:' + ]; + + foreach( $validSqliteUrls as $url ) + { + // SQLite URLs are valid if they start with 'sqlite:' + $this->assertTrue( str_starts_with( $url, 'sqlite:' ), "Should be valid SQLite URL: $url" ); + } + } +} \ No newline at end of file diff --git a/tests/Unit/Cms/Controllers/Admin/PostsTest.php b/tests/Unit/Cms/Controllers/Admin/PostsTest.php index 229cf9b..d1561ec 100644 --- a/tests/Unit/Cms/Controllers/Admin/PostsTest.php +++ b/tests/Unit/Cms/Controllers/Admin/PostsTest.php @@ -35,6 +35,14 @@ protected function setUp(): void // Setup mock settings for Registry $mockSettings = $this->createMock( SettingManager::class ); $mockSettings->method( 'get' )->willReturn( 'Test Site' ); + + // Create a mock setting source for the SettingManager + $mockSettingSource = $this->createMock( \Neuron\Data\Settings\Source\ISettingSource::class ); + $mockSettingSource->method( 'get' )->willReturn( false ); // Cache disabled + + // IMPORTANT: Mock the getSource() method to return the mock setting source + $mockSettings->method( 'getSource' )->willReturn( $mockSettingSource ); + Registry::getInstance()->set( 'Settings', $mockSettings ); // Setup container to return mocks diff --git a/tests/Unit/Cms/Controllers/Auth/PasswordResetTest.php b/tests/Unit/Cms/Controllers/Auth/PasswordResetTest.php index b1a5ffd..8a22112 100644 --- a/tests/Unit/Cms/Controllers/Auth/PasswordResetTest.php +++ b/tests/Unit/Cms/Controllers/Auth/PasswordResetTest.php @@ -48,6 +48,14 @@ protected function setUp(): void if( $section === 'cache' && $key === 'enabled' ) return false; return 'Test Site'; }); + + // Create a mock setting source for the SettingManager + $mockSettingSource = $this->createMock( \Neuron\Data\Settings\Source\ISettingSource::class ); + $mockSettingSource->method( 'get' )->willReturn( false ); // Cache disabled + + // IMPORTANT: Mock the getSource() method to return the mock setting source + $mockSettings->method( 'getSource' )->willReturn( $mockSettingSource ); + Registry::getInstance()->set( 'Settings', $mockSettings ); Registry::getInstance()->set( 'Views.Path', __DIR__ . '/../../../../../resources/views' ); diff --git a/tests/Unit/Cms/Controllers/Member/RegistrationTest.php b/tests/Unit/Cms/Controllers/Member/RegistrationTest.php index 6685d0d..ca24e52 100644 --- a/tests/Unit/Cms/Controllers/Member/RegistrationTest.php +++ b/tests/Unit/Cms/Controllers/Member/RegistrationTest.php @@ -48,6 +48,20 @@ protected function setUp(): void $this->mockSettings = $this->createMock( SettingManager::class ); $this->mockContainer = $this->createMock( IContainer::class ); + // Create a mock setting source for the SettingManager + $mockSettingSource = $this->createMock( \Neuron\Data\Settings\Source\ISettingSource::class ); + $mockSettingSource->method( 'get' )->willReturnCallback( function( $section, $key = null ) { + if( $section === 'site' && $key === 'name' ) return 'Test Site'; + if( $section === 'site' && $key === 'title' ) return 'Test Site'; + if( $section === 'site' && $key === 'description' ) return 'Test Description'; + if( $section === 'site' && $key === 'url' ) return 'https://test.com'; + if( $section === 'site' && $key === 'theme' ) return 'flatly'; + if( $section === 'views' && $key === 'path' ) return __DIR__ . '/../../../../../resources/views'; + if( $section === 'cache' && $key === 'enabled' ) return false; + if( $section === 'member' && $key === 'require_email_verification' ) return true; + return null; + }); + // Setup default mock settings $this->mockSettings->method( 'get' )->willReturnCallback( function( $section, $key = null ) { if( $section === 'site' && $key === 'name' ) return 'Test Site'; @@ -61,6 +75,9 @@ protected function setUp(): void return null; }); + // IMPORTANT: Mock the getSource() method to return the mock setting source + $this->mockSettings->method( 'getSource' )->willReturn( $mockSettingSource ); + Registry::getInstance()->set( 'Settings', $this->mockSettings ); Registry::getInstance()->set( 'Views.Path', __DIR__ . '/../../../../../resources/views' ); diff --git a/tests/Unit/Cms/Database/ConnectionFactoryIntegrationTest.php b/tests/Unit/Cms/Database/ConnectionFactoryIntegrationTest.php new file mode 100644 index 0000000..2d6481e --- /dev/null +++ b/tests/Unit/Cms/Database/ConnectionFactoryIntegrationTest.php @@ -0,0 +1,89 @@ +set( 'database', 'url', 'sqlite::memory:' ); + + $settings = new SettingManager( $secretsSource ); + + // This should work without any "Unsupported database adapter" errors + $pdo = ConnectionFactory::createFromSettings( $settings ); + + $this->assertInstanceOf( PDO::class, $pdo ); + + // Verify it's actually SQLite + $driver = $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + $this->assertEquals( 'sqlite', $driver ); + } + + /** + * Test that mixing URL in secrets with public config (non-database) works + */ + public function testUrlFromSecretsWithOtherPublicConfig(): void + { + // Public config has site settings but NO database section + $publicSource = new Memory(); + $publicSource->set( 'site', 'name', 'My Site' ); + $publicSource->set( 'cache', 'enabled', true ); + + // Secrets has database URL + $secretsSource = new Memory(); + $secretsSource->set( 'database', 'url', 'sqlite::memory:' ); + + // Combine like the real app does + $settings = new SettingManager( $publicSource ); + $settings->addSource( $secretsSource, 'secrets' ); + + // Should create connection successfully + $pdo = ConnectionFactory::createFromSettings( $settings ); + $this->assertInstanceOf( PDO::class, $pdo ); + } + + /** + * Test traditional configuration still works (non-URL) + */ + public function testTraditionalConfigurationStillWorks(): void + { + $source = new Memory(); + $source->set( 'database', 'adapter', 'sqlite' ); + $source->set( 'database', 'name', ':memory:' ); + + $settings = new SettingManager( $source ); + $pdo = ConnectionFactory::createFromSettings( $settings ); + + $this->assertInstanceOf( PDO::class, $pdo ); + $this->assertEquals( 'sqlite', $pdo->getAttribute( PDO::ATTR_DRIVER_NAME ) ); + } + + /** + * Test that URL configuration works without secrets + */ + public function testUrlWithoutSecrets(): void + { + $source = new Memory(); + $source->set( 'database', 'url', 'sqlite::memory:' ); + + $settings = new SettingManager( $source ); + $pdo = ConnectionFactory::createFromSettings( $settings ); + + $this->assertInstanceOf( PDO::class, $pdo ); + } +} \ No newline at end of file diff --git a/tests/Unit/Cms/Database/ConnectionFactoryTest.php b/tests/Unit/Cms/Database/ConnectionFactoryTest.php new file mode 100644 index 0000000..c397697 --- /dev/null +++ b/tests/Unit/Cms/Database/ConnectionFactoryTest.php @@ -0,0 +1,328 @@ +getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( null, [ 'mysql://testuser:testpass@localhost:3306/testdb?charset=utf8mb4' ] ); + + $this->assertEquals( 'mysql', $result['adapter'] ); + $this->assertEquals( 'localhost', $result['host'] ); + $this->assertEquals( 3306, $result['port'] ); + $this->assertEquals( 'testuser', $result['user'] ); + $this->assertEquals( 'testpass', $result['pass'] ); + $this->assertEquals( 'testdb', $result['name'] ); + $this->assertEquals( 'utf8mb4', $result['charset'] ); + } + + /** + * Test parsing PostgreSQL URL with various schemes + */ + public function testParsePostgresqlUrl(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + // Test different PostgreSQL URL schemes + $urls = [ + 'postgresql://user:pass@host:5432/dbname', + 'postgres://user:pass@host:5432/dbname', + 'pgsql://user:pass@host:5432/dbname' + ]; + + foreach( $urls as $url ) + { + $result = $method->invokeArgs( null, [ $url ] ); + $this->assertEquals( 'pgsql', $result['adapter'] ); + $this->assertEquals( 'host', $result['host'] ); + $this->assertEquals( 5432, $result['port'] ); + $this->assertEquals( 'user', $result['user'] ); + $this->assertEquals( 'pass', $result['pass'] ); + $this->assertEquals( 'dbname', $result['name'] ); + } + } + + /** + * Test parsing SQLite URLs + */ + public function testParseSqliteUrl(): void + { + // Test :memory: database + $settings = $this->createSettingsWithUrl( 'sqlite::memory:' ); + $pdo = ConnectionFactory::createFromSettings( $settings ); + $this->assertInstanceOf( PDO::class, $pdo ); + + // Test with absolute path + $tempFile = tempnam( sys_get_temp_dir(), 'test_db_' ) . '.sqlite3'; + $settings = $this->createSettingsWithUrl( 'sqlite:///' . $tempFile ); + $pdo = ConnectionFactory::createFromSettings( $settings ); + $this->assertInstanceOf( PDO::class, $pdo ); + + // Clean up + if( file_exists( $tempFile ) ) + { + unlink( $tempFile ); + } + } + + /** + * Test URL with individual parameter overrides + */ + public function testUrlWithOverrides(): void + { + // Create a Memory source with URL and overrides + $source = new Memory(); + $source->set( 'database', 'url', 'mysql://user:pass@urlhost:3306/urldb' ); + $source->set( 'database', 'host', 'overridehost' ); + $source->set( 'database', 'port', 9999 ); + + // Create SettingManager with the source + $settings = new SettingManager( $source ); + $config = $settings->getSection( 'database' ); + + // Verify the configuration has both URL and overrides + $this->assertArrayHasKey( 'url', $config ); + $this->assertEquals( 'overridehost', $config['host'] ); + $this->assertEquals( 9999, $config['port'] ); + + // Now test that createFromConfig properly applies the overrides + // We'll use a mock PDO to test the DSN construction without actual connection + try { + // Call createFromConfig through reflection to catch the DSN + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $createMethod = $reflection->getMethod( 'createFromConfig' ); + + // Instead of creating actual PDO, let's verify the merge logic + // by inspecting what createFromConfig would do + $parseMethod = $reflection->getMethod( 'parseUrl' ); + $parseMethod->setAccessible( true ); + + // Parse the URL to get base config + $urlConfig = $parseMethod->invokeArgs( null, [ $config['url'] ] ); + + // Apply the same merge logic that createFromConfig uses + $finalConfig = array_merge( + $urlConfig, + array_filter( $config, function( $value, $key ) { + return $key !== 'url' && $value !== null; + }, ARRAY_FILTER_USE_BOTH ) + ); + + // Verify that overrides were applied + $this->assertEquals( 'overridehost', $finalConfig['host'], 'Host should be overridden' ); + $this->assertEquals( 9999, $finalConfig['port'], 'Port should be overridden' ); + + // Verify other values from URL are preserved + $this->assertEquals( 'user', $finalConfig['user'], 'User from URL should be preserved' ); + $this->assertEquals( 'pass', $finalConfig['pass'], 'Password from URL should be preserved' ); + $this->assertEquals( 'urldb', $finalConfig['name'], 'Database name from URL should be preserved' ); + $this->assertEquals( 'mysql', $finalConfig['adapter'], 'Adapter from URL should be preserved' ); + + // Verify the DSN that would be constructed uses the overrides + $expectedDsn = sprintf( + "mysql:host=%s;port=%s;dbname=%s;charset=%s", + 'overridehost', // Override host + 9999, // Override port + 'urldb', // From URL + 'utf8mb4' // Default charset + ); + + // Build actual DSN using same logic as ConnectionFactory + $actualDsn = sprintf( + "mysql:host=%s;port=%s;dbname=%s;charset=%s", + $finalConfig['host'] ?? 'localhost', + $finalConfig['port'] ?? 3306, + $finalConfig['name'], + $finalConfig['charset'] ?? 'utf8mb4' + ); + + $this->assertEquals( $expectedDsn, $actualDsn, 'DSN should use overridden host and port' ); + + } catch ( \PDOException $e ) { + // If PDO fails (no actual MySQL), that's expected - we're testing config merge logic + $this->markTestSkipped( 'Cannot test actual PDO connection without MySQL server' ); + } + } + + /** + * Test malformed URL handling + */ + public function testMalformedUrlThrowsException(): void + { + $settings = $this->createSettingsWithUrl( 'not-a-valid-url' ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Malformed database URL' ); + + ConnectionFactory::createFromSettings( $settings ); + } + + /** + * Test unsupported scheme handling + */ + public function testUnsupportedSchemeThrowsException(): void + { + $settings = $this->createSettingsWithUrl( 'mongodb://localhost:27017/testdb' ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'Unsupported database scheme: mongodb' ); + + ConnectionFactory::createFromSettings( $settings ); + } + + /** + * Test URL with query parameters + */ + public function testUrlWithQueryParameters(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( null, [ 'mysql://user:pass@localhost:3306/testdb?charset=latin1' ] ); + + // The charset from query parameter should be preserved + $this->assertEquals( 'latin1', $result['charset'] ); + } + + /** + * Test backward compatibility - existing configs without URL should still work + */ + public function testBackwardCompatibility(): void + { + $source = new Memory(); + $source->set( 'database', 'adapter', 'sqlite' ); + $source->set( 'database', 'name', ':memory:' ); + + $settings = new SettingManager( $source ); + $pdo = ConnectionFactory::createFromSettings( $settings ); + + $this->assertInstanceOf( PDO::class, $pdo ); + } + + /** + * Test URL without password + */ + public function testUrlWithoutPassword(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( null, [ 'mysql://user@localhost:3306/testdb' ] ); + + // Should parse without error (password will be null) + $this->assertEquals( 'user', $result['user'] ); + $this->assertArrayNotHasKey( 'pass', $result ); + } + + /** + * Test URL without port (should use defaults) + */ + public function testUrlWithoutPort(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( null, [ 'mysql://user:pass@localhost/testdb' ] ); + + // Should parse without error (port will use default) + $this->assertEquals( 'localhost', $result['host'] ); + $this->assertEquals( 'testdb', $result['name'] ); + $this->assertArrayNotHasKey( 'port', $result ); // Port not in URL, will use default + } + + /** + * Test URL with encoded special characters in credentials + */ + public function testUrlWithEncodedCredentials(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + // Test password with @ symbol (encoded as %40) + $result = $method->invokeArgs( null, [ 'mysql://user:p%40ssw%3Ard@localhost:3306/testdb' ] ); + + // Credentials should be decoded + $this->assertEquals( 'user', $result['user'] ); + $this->assertEquals( 'p@ssw:rd', $result['pass'] ); // %40 becomes @, %3A becomes : + } + + /** + * Test SQLite URL with triple slash (absolute path) + */ + public function testSqliteUrlWithTripleSlash(): void + { + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( null, [ 'sqlite:///storage/database.sqlite3' ] ); + + // Should preserve absolute path with single leading slash + $this->assertEquals( 'sqlite', $result['adapter'] ); + $this->assertEquals( '/storage/database.sqlite3', $result['name'] ); + } + + /** + * Test that URL configuration doesn't conflict with other config keys + * This simulates the scenario where URL comes from secrets and public config has other keys + */ + public function testUrlDoesNotConflictWithOtherConfig(): void + { + $source = new Memory(); + // Simulate URL from secrets and no adapter in public config + $source->set( 'database', 'url', 'mysql://user:pass@localhost:3306/testdb' ); + // No 'adapter' key should be set to avoid conflicts + + $settings = new SettingManager( $source ); + $config = $settings->getSection( 'database' ); + + // The URL should be the only thing in the config + $this->assertArrayHasKey( 'url', $config ); + $this->assertArrayNotHasKey( 'adapter', $config ); + + // ConnectionFactory should be able to create from this config + // (We can't test actual PDO creation without a real database, + // but we can verify the config is parseable) + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $method = $reflection->getMethod( 'parseUrl' ); + $method->setAccessible( true ); + + $parsed = $method->invokeArgs( null, [ $config['url'] ] ); + $this->assertEquals( 'mysql', $parsed['adapter'] ); + } + + /** + * Helper method to create settings with a database URL + */ + private function createSettingsWithUrl( string $url ): SettingManager + { + $source = new Memory(); + $source->set( 'database', 'url', $url ); + return new SettingManager( $source ); + } +} \ No newline at end of file diff --git a/tests/Unit/Cms/Database/ConnectionFactoryUrlSecretsTest.php b/tests/Unit/Cms/Database/ConnectionFactoryUrlSecretsTest.php new file mode 100644 index 0000000..022fd79 --- /dev/null +++ b/tests/Unit/Cms/Database/ConnectionFactoryUrlSecretsTest.php @@ -0,0 +1,117 @@ +set( 'site', 'name', 'Test Site' ); + + $secretsSource = new Memory(); + // Secrets has the database URL + $secretsSource->set( 'database', 'url', 'mysql://user:pass@localhost:3306/testdb' ); + + // Combine sources like the real app does + $settings = new SettingManager( $publicSource ); + $settings->addSource( $secretsSource, 'secrets' ); + + // Get the merged database config + $config = $settings->getSection( 'database' ); + + // Should only have URL, no adapter key to cause conflicts + $this->assertArrayHasKey( 'url', $config ); + $this->assertArrayNotHasKey( 'adapter', $config ); + $this->assertCount( 1, $config, 'Database config should only contain URL' ); + + // Now test that ConnectionFactory can parse this correctly + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $parseMethod = $reflection->getMethod( 'parseUrl' ); + $parseMethod->setAccessible( true ); + + $parsed = $parseMethod->invokeArgs( null, [ $config['url'] ] ); + + // Verify the adapter is correctly extracted from URL + $this->assertEquals( 'mysql', $parsed['adapter'] ); + $this->assertEquals( 'localhost', $parsed['host'] ); + $this->assertEquals( 3306, $parsed['port'] ); + $this->assertEquals( 'testdb', $parsed['name'] ); + $this->assertEquals( 'user', $parsed['user'] ); + $this->assertEquals( 'pass', $parsed['pass'] ); + } + + /** + * Test that old broken configuration would fail (for comparison) + * This demonstrates what would happen if we had 'adapter' => 'configured' + */ + public function testBrokenConfigurationWithPlaceholderAdapter(): void + { + $source = new Memory(); + // This simulates the BROKEN configuration that would cause the error + $source->set( 'database', 'url', 'mysql://user:pass@localhost:3306/testdb' ); + $source->set( 'database', 'adapter', 'configured' ); // This would break it! + + $settings = new SettingManager( $source ); + $config = $settings->getSection( 'database' ); + + // Both keys would be present + $this->assertArrayHasKey( 'url', $config ); + $this->assertArrayHasKey( 'adapter', $config ); + + // The adapter would be the broken placeholder + $this->assertEquals( 'configured', $config['adapter'] ); + + // This configuration would cause "Unsupported database adapter: configured" error + // because the placeholder would override the URL-parsed adapter + } + + /** + * Test the actual merging logic that happens in createFromConfig + */ + public function testCreateFromConfigMergingLogic(): void + { + // Test the exact merging that happens in ConnectionFactory::createFromConfig + $config = [ 'url' => 'postgresql://user:pass@host:5432/dbname' ]; + + $reflection = new \ReflectionClass( ConnectionFactory::class ); + $parseMethod = $reflection->getMethod( 'parseUrl' ); + $parseMethod->setAccessible( true ); + + // Parse URL + $urlConfig = $parseMethod->invokeArgs( null, [ $config['url'] ] ); + + // Simulate the merge that happens in createFromConfig + $mergedConfig = array_merge( + $urlConfig, + array_filter( $config, function( $value, $key ) { + return $key !== 'url' && $value !== null; + }, ARRAY_FILTER_USE_BOTH ) + ); + + // The adapter from URL should be preserved (no override) + $this->assertEquals( 'pgsql', $mergedConfig['adapter'] ); + $this->assertEquals( 'host', $mergedConfig['host'] ); + $this->assertEquals( 5432, $mergedConfig['port'] ); + $this->assertEquals( 'dbname', $mergedConfig['name'] ); + } +} \ No newline at end of file diff --git a/tests/Unit/Cms/Database/NoConfiguredAdapterTest.php b/tests/Unit/Cms/Database/NoConfiguredAdapterTest.php new file mode 100644 index 0000000..8a9ee4f --- /dev/null +++ b/tests/Unit/Cms/Database/NoConfiguredAdapterTest.php @@ -0,0 +1,80 @@ +set( 'database', 'url', 'sqlite::memory:' ); + + // Merge like the app does + $settings = new SettingManager( $publicSource ); + $settings->addSource( $secretsSource ); + + // This should NOT throw "Unsupported database adapter: configured" + try { + $pdo = ConnectionFactory::createFromSettings( $settings ); + $this->assertNotNull( $pdo ); + $this->assertEquals( 'sqlite', $pdo->getAttribute( \PDO::ATTR_DRIVER_NAME ) ); + } catch ( \Exception $e ) { + // If we get here, check it's NOT the "configured" error + $this->assertStringNotContainsString( + 'Unsupported database adapter: configured', + $e->getMessage(), + 'Should not get "configured" adapter error' + ); + } + } + + /** + * Test what would happen if we DID have 'adapter' => 'configured' (broken scenario) + */ + public function testBrokenScenarioWithConfiguredAdapter(): void + { + $settings = new Memory(); + $settings->set( 'database', 'url', 'sqlite::memory:' ); + $settings->set( 'database', 'adapter', 'configured' ); // This would be the bug + + $settingManager = new SettingManager( $settings ); + + // This WOULD throw an error because 'configured' is not a valid adapter + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Unsupported database adapter: configured' ); + + ConnectionFactory::createFromSettings( $settingManager ); + } + + /** + * Test the fixed scenario - URL only, no adapter key + */ + public function testFixedScenarioUrlOnly(): void + { + $settings = new Memory(); + $settings->set( 'database', 'url', 'sqlite::memory:' ); + // NO adapter key set - this is the fix + + $settingManager = new SettingManager( $settings ); + + // This should work perfectly + $pdo = ConnectionFactory::createFromSettings( $settingManager ); + $this->assertNotNull( $pdo ); + $this->assertEquals( 'sqlite', $pdo->getAttribute( \PDO::ATTR_DRIVER_NAME ) ); + } +} \ No newline at end of file