From 9f1b76310fc28158ef69ff4e01c11a62154fdf4c Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sat, 14 Mar 2026 13:51:03 +0100 Subject: [PATCH] feat(config): add modern PSR-4 config loaders Add four specialized config loaders with Vhost support: - ConfigLoader: Application config (conf.php) - BackendConfigLoader: Backend definitions (backends.php) - PrefsConfigLoader: Preference definitions (prefs.php) - RegistryConfigLoader: Global app registry (registry.php) Each loader loads from 5 layers: vendor defaults, base config, snippets, local overrides, and vhost overrides. All loaders return immutable state objects and never populate globals. Vhost detection externalized to Vhost object supporting auto- detection from $_SERVER or explicit hostname. No circular dependencies on config values. style: php-cs-fixer --- src/Assets/ResponsiveAssets.php | 6 +- src/Auth/Jwt/GeneratedJwt.php | 3 +- src/Auth/Jwt/PrivateKey.php | 3 +- src/Auth/Jwt/VerifiedJwt.php | 3 +- src/Config/BackendConfigLoader.php | 218 ++++++++++++ src/Config/BackendState.php | 90 +++++ src/Config/ConfigLoader.php | 155 +++++++++ src/Config/PrefsConfigLoader.php | 269 +++++++++++++++ src/Config/PrefsState.php | 108 ++++++ src/Config/RegistryConfigLoader.php | 231 +++++++++++++ src/Config/RegistryState.php | 82 +++++ src/Config/State.php | 79 ++++- src/Config/Vhost.php | 117 +++++++ src/Controller/ResponsiveControllerTrait.php | 3 +- src/Middleware/AppRouter.php | 67 ++++ src/View/ResponsiveTopbar.php | 1 + test/Config/BackendConfigLoaderTest.php | 328 +++++++++++++++++++ test/Config/BackendStateTest.php | 116 +++++++ test/Config/PrefsStateTest.php | 128 ++++++++ test/Config/RegistryStateTest.php | 86 +++++ test/Config/VhostTest.php | 141 ++++++++ 21 files changed, 2220 insertions(+), 14 deletions(-) create mode 100644 src/Config/BackendConfigLoader.php create mode 100644 src/Config/BackendState.php create mode 100644 src/Config/ConfigLoader.php create mode 100644 src/Config/PrefsConfigLoader.php create mode 100644 src/Config/PrefsState.php create mode 100644 src/Config/RegistryConfigLoader.php create mode 100644 src/Config/RegistryState.php create mode 100644 src/Config/Vhost.php create mode 100644 test/Config/BackendConfigLoaderTest.php create mode 100644 test/Config/BackendStateTest.php create mode 100644 test/Config/PrefsStateTest.php create mode 100644 test/Config/RegistryStateTest.php create mode 100644 test/Config/VhostTest.php diff --git a/src/Assets/ResponsiveAssets.php b/src/Assets/ResponsiveAssets.php index 5eda732b..7926576f 100644 --- a/src/Assets/ResponsiveAssets.php +++ b/src/Assets/ResponsiveAssets.php @@ -63,8 +63,8 @@ public function __construct( public function getCssUrls(?string $theme = null, ?string $app = null): array { $urls = []; - $theme = $theme ?? $this->getThemePreference(); - $app = $app ?? $this->registry->getApp(); + $theme ??= $this->getThemePreference(); + $app ??= $this->registry->getApp(); $cssFiles = ['responsive.css']; @@ -107,7 +107,7 @@ public function getCssUrls(?string $theme = null, ?string $app = null): array public function getJsUrls(array $jsFiles = [], ?string $app = null): array { $urls = []; - $app = $app ?? $this->registry->getApp(); + $app ??= $this->registry->getApp(); foreach ($jsFiles as $file) { // Check Horde base JS diff --git a/src/Auth/Jwt/GeneratedJwt.php b/src/Auth/Jwt/GeneratedJwt.php index bd928499..a786dc40 100644 --- a/src/Auth/Jwt/GeneratedJwt.php +++ b/src/Auth/Jwt/GeneratedJwt.php @@ -27,7 +27,8 @@ public function __construct( public readonly string $token, public readonly int $expiresAt, public readonly array $claims = [] - ) {} + ) { + } /** * Get a specific claim value diff --git a/src/Auth/Jwt/PrivateKey.php b/src/Auth/Jwt/PrivateKey.php index 7df028b8..cf02bbce 100644 --- a/src/Auth/Jwt/PrivateKey.php +++ b/src/Auth/Jwt/PrivateKey.php @@ -25,7 +25,8 @@ class PrivateKey */ private function __construct( public readonly string $content - ) {} + ) { + } /** * Create from PEM string content diff --git a/src/Auth/Jwt/VerifiedJwt.php b/src/Auth/Jwt/VerifiedJwt.php index 6c3afa1a..23e028e6 100644 --- a/src/Auth/Jwt/VerifiedJwt.php +++ b/src/Auth/Jwt/VerifiedJwt.php @@ -25,7 +25,8 @@ class VerifiedJwt public function __construct( public readonly string $token, public readonly array $claims - ) {} + ) { + } /** * Get a specific claim value diff --git a/src/Config/BackendConfigLoader.php b/src/Config/BackendConfigLoader.php new file mode 100644 index 00000000..12f14335 --- /dev/null +++ b/src/Config/BackendConfigLoader.php @@ -0,0 +1,218 @@ +vhost = Vhost::from($this->vhost); + } + + /** + * Load backend definitions for an app + * + * @param string $app App name ('passwd', 'imp', 'ingo', etc.) + * @param string $file Config filename (default: 'backends.php') + * @return BackendState Immutable backend state + */ + public function load(string $app, string $file = 'backends.php'): BackendState + { + $cacheKey = $app . ':' . $file; + + if (!isset($this->cache[$cacheKey])) { + $backends = $this->loadFiles($app, $file); + $this->cache[$cacheKey] = new BackendState($backends); + } + + return $this->cache[$cacheKey]; + } + + /** + * Load specific layer for introspection + * + * @param string $app App name + * @param string $layer Layer name ('vendor', 'base', 'snippets', 'local', 'vhost') + * @param string $file Config filename + * @return array Raw config from that layer only + */ + public function loadLayer(string $app, string $layer, string $file = 'backends.php'): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/' . $app . '/'; + $vendorDir = $this->vendorBase . '/' . $app . '/config/'; + + switch ($layer) { + case 'vendor': + $filePath = $vendorDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'base': + $filePath = $confDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'snippets': + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (!is_dir($snippetsDir)) { + return []; + } + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets === false) { + return []; + } + sort($snippets); + $merged = []; + foreach ($snippets as $snippetFile) { + $loaded = $this->includeFile($snippetFile); + $merged = array_replace_recursive($merged, $loaded); + } + return $merged; + + case 'local': + $filePath = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'vhost': + // Need to check if vhosts enabled - requires loading base config first + // For introspection, just return empty if vhost file doesn't exist + $vhostFile = $confDir . $pathInfo['filename'] . '-*.'. $pathInfo['extension']; + $vhostFiles = glob($vhostFile); + if ($vhostFiles === false || empty($vhostFiles)) { + return []; + } + // Return first vhost file found (for introspection purposes) + return $this->includeFile($vhostFiles[0]); + + default: + throw new RuntimeException("Unknown layer: $layer"); + } + } + + /** + * Clear cache (useful for testing) + */ + public function clearCache(): void + { + $this->cache = []; + } + + /** + * Load and merge backend files + * + * @param string $app App name + * @param string $file Config filename + * @return array Merged backends array + */ + private function loadFiles(string $app, string $file): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/' . $app . '/'; + $vendorDir = $this->vendorBase . '/' . $app . '/config/'; + + // Build file list + $files = []; + + // 1. Vendor defaults (factory defaults) + $files[] = $vendorDir . $file; + + // 2. Deployment base config + $files[] = $confDir . $file; + + // 3. Config snippets (backends.d/) + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (is_dir($snippetsDir)) { + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets !== false) { + sort($snippets); // Load in alphabetical order + $files = array_merge($files, $snippets); + } + } + + // 4. Local override + $localFile = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + $files[] = $localFile; + + // Load files + $backends = []; + + foreach ($files as $filePath) { + if (file_exists($filePath)) { + // Include file in isolated scope + $loadedBackends = $this->includeFile($filePath); + + // Merge backends array + $backends = array_replace_recursive($backends, $loadedBackends); + } + } + + // 5. VHost override (via Vhost object) + if ($this->vhost->isAvailable()) { + $vhostFilename = $this->vhost->getVhostFilename($file); + if ($vhostFilename) { + $vhostFile = $confDir . $vhostFilename; + if (file_exists($vhostFile)) { + $loadedBackends = $this->includeFile($vhostFile); + $backends = array_replace_recursive($backends, $loadedBackends); + } + } + } + + return $backends; + } + + /** + * Include PHP file in isolated scope and extract $backends variable + * + * @param string $file File path + * @return array Extracted backends array + */ + private function includeFile(string $file): array + { + // Define variables that config files expect + $backends = []; + + // Include file + include $file; + + // Return $backends variable + return $backends; + } +} diff --git a/src/Config/BackendState.php b/src/Config/BackendState.php new file mode 100644 index 00000000..dc224752 --- /dev/null +++ b/src/Config/BackendState.php @@ -0,0 +1,90 @@ +backends[$name] ?? null; + } + + /** + * List all backends + * + * @param bool $includeDisabled Include disabled backends (default: false) + * @return array Backend definitions keyed by name + */ + public function listBackends(bool $includeDisabled = false): array + { + if ($includeDisabled) { + return $this->backends; + } + + return array_filter( + $this->backends, + fn ($backend) => empty($backend['disabled']) + ); + } + + /** + * Check if backend exists + * + * @param string $name Backend name + * @return bool True if backend exists (regardless of disabled status) + */ + public function hasBackend(string $name): bool + { + return isset($this->backends[$name]); + } + + /** + * Get all backends as array (for legacy compatibility) + * + * @return array Raw backends array + */ + public function toArray(): array + { + return $this->backends; + } +} diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php new file mode 100644 index 00000000..cda67832 --- /dev/null +++ b/src/Config/ConfigLoader.php @@ -0,0 +1,155 @@ +vhost = Vhost::from($this->vhost); + } + + /** + * Load config for an app + * + * @param string $app App name (e.g., 'horde', 'imp', 'turba') + * @param string $file Config filename (default: 'conf.php') + * @return State Immutable config state + */ + public function load(string $app, string $file = 'conf.php'): State + { + $cacheKey = $app . ':' . $file; + + if (!isset($this->cache[$cacheKey])) { + $config = $this->loadFiles($app, $file); + $this->cache[$cacheKey] = new State($config); + } + + return $this->cache[$cacheKey]; + } + + /** + * Load and merge config files + * + * @param string $app App name + * @param string $file Config filename + * @return array Merged config array + */ + private function loadFiles(string $app, string $file): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/' . $app . '/'; + + // Build file list + $files = []; + + // 1. Main config + $files[] = $confDir . $file; + + // 2. Config snippets (conf.d/) + $confDDir = $confDir . $pathInfo['filename'] . '.d'; + if (is_dir($confDDir)) { + $snippets = glob($confDDir . '/*.php'); + if ($snippets !== false) { + sort($snippets); // Load in alphabetical order + $files = array_merge($files, $snippets); + } + } + + // 3. Local override + $localFile = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + $files[] = $localFile; + + // Load files + $conf = []; + + foreach ($files as $filePath) { + if (file_exists($filePath)) { + // Include file in isolated scope + $loadedVars = $this->includeFile($filePath); + + // Merge $conf array + if (isset($loadedVars['conf'])) { + $conf = array_replace_recursive($conf, $loadedVars['conf']); + } + } + } + + // 4. VHost override (via Vhost object) + if ($this->vhost->isAvailable()) { + $vhostFilename = $this->vhost->getVhostFilename($file); + if ($vhostFilename) { + $vhostFile = $confDir . $vhostFilename; + if (file_exists($vhostFile)) { + $loadedVars = $this->includeFile($vhostFile); + if (isset($loadedVars['conf'])) { + $conf = array_replace_recursive($conf, $loadedVars['conf']); + } + } + } + } + + return $conf; + } + + /** + * Include PHP file in isolated scope and extract variables + * + * @param string $file File path + * @return array Extracted variables + */ + private function includeFile(string $file): array + { + // Define variables that config files expect + $conf = []; + + // Include file + include $file; + + // Return all defined variables + return get_defined_vars(); + } + + /** + * Clear cache (useful for testing) + */ + public function clearCache(): void + { + $this->cache = []; + } +} diff --git a/src/Config/PrefsConfigLoader.php b/src/Config/PrefsConfigLoader.php new file mode 100644 index 00000000..1855d48b --- /dev/null +++ b/src/Config/PrefsConfigLoader.php @@ -0,0 +1,269 @@ +vhost = Vhost::from($this->vhost); + } + + /** + * Load preference definitions for an app + * + * @param string $app App name ('horde', 'turba', 'imp', etc.) + * @param string $file Config filename (default: 'prefs.php') + * @return PrefsState Immutable prefs state + */ + public function load(string $app, string $file = 'prefs.php'): PrefsState + { + $cacheKey = $app . ':' . $file; + + if (!isset($this->cache[$cacheKey])) { + $config = $this->loadFiles($app, $file); + $this->cache[$cacheKey] = new PrefsState( + $config['_prefs'] ?? [], + $config['prefGroups'] ?? [] + ); + } + + return $this->cache[$cacheKey]; + } + + /** + * Load specific layer for introspection + * + * @param string $app App name + * @param string $layer Layer name ('vendor', 'base', 'snippets', 'local', 'vhost') + * @param string $file Config filename + * @return array Raw config from that layer (['_prefs' => [...], 'prefGroups' => [...]]) + */ + public function loadLayer(string $app, string $layer, string $file = 'prefs.php'): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/' . $app . '/'; + $vendorDir = $this->vendorBase . '/' . $app . '/config/'; + + switch ($layer) { + case 'vendor': + $filePath = $vendorDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'base': + $filePath = $confDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'snippets': + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (!is_dir($snippetsDir)) { + return []; + } + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets === false) { + return []; + } + sort($snippets); + $merged = ['_prefs' => [], 'prefGroups' => []]; + foreach ($snippets as $snippetFile) { + $loaded = $this->includeFile($snippetFile); + $merged = $this->mergePrefs($merged, $loaded); + } + return $merged; + + case 'local': + $filePath = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'vhost': + if (!$this->vhost->isAvailable()) { + return []; + } + $vhostFilename = $this->vhost->getVhostFilename($file); + if (!$vhostFilename) { + return []; + } + $vhostFile = $confDir . $vhostFilename; + return file_exists($vhostFile) ? $this->includeFile($vhostFile) : []; + + default: + throw new RuntimeException("Unknown layer: $layer"); + } + } + + /** + * Clear cache (useful for testing) + */ + public function clearCache(): void + { + $this->cache = []; + } + + /** + * Load and merge prefs files + * + * @param string $app App name + * @param string $file Config filename + * @return array Merged config ['_prefs' => [...], 'prefGroups' => [...]] + */ + private function loadFiles(string $app, string $file): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/' . $app . '/'; + $vendorDir = $this->vendorBase . '/' . $app . '/config/'; + + // Build file list + $files = []; + + // 1. Vendor defaults (factory defaults) + $files[] = $vendorDir . $file; + + // 2. Deployment base config + $files[] = $confDir . $file; + + // 3. Config snippets (prefs.d/) + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (is_dir($snippetsDir)) { + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets !== false) { + sort($snippets); // Load in alphabetical order + $files = array_merge($files, $snippets); + } + } + + // 4. Local override + $localFile = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + $files[] = $localFile; + + // Load files + $config = ['_prefs' => [], 'prefGroups' => []]; + + foreach ($files as $filePath) { + if (file_exists($filePath)) { + // Include file in isolated scope + $loaded = $this->includeFile($filePath); + + // Merge prefs and groups + $config = $this->mergePrefs($config, $loaded); + } + } + + // 5. VHost override (via Vhost object) + if ($this->vhost->isAvailable()) { + $vhostFilename = $this->vhost->getVhostFilename($file); + if ($vhostFilename) { + $vhostFile = $confDir . $vhostFilename; + if (file_exists($vhostFile)) { + $loaded = $this->includeFile($vhostFile); + $config = $this->mergePrefs($config, $loaded); + } + } + } + + return $config; + } + + /** + * Merge prefs configuration preserving closures + * + * @param array $existing Existing config + * @param array $new New config to merge + * @return array Merged config + */ + private function mergePrefs(array $existing, array $new): array + { + // Merge $_prefs + if (isset($new['_prefs'])) { + foreach ($new['_prefs'] as $key => $value) { + if (isset($existing['_prefs'][$key]) && is_array($existing['_prefs'][$key]) && is_array($value)) { + // Merge pref definitions + // Special handling: closures cannot be merged, keep first one + foreach ($value as $prefKey => $prefValue) { + if ($prefValue instanceof \Closure) { + // Keep existing closure if present, otherwise use new + if (!isset($existing['_prefs'][$key][$prefKey]) || !($existing['_prefs'][$key][$prefKey] instanceof \Closure)) { + $existing['_prefs'][$key][$prefKey] = $prefValue; + } + } else { + // Normal value, merge + $existing['_prefs'][$key][$prefKey] = $prefValue; + } + } + } else { + // Replace entire pref + $existing['_prefs'][$key] = $value; + } + } + } + + // Merge prefGroups + if (isset($new['prefGroups'])) { + $existing['prefGroups'] = array_replace_recursive( + $existing['prefGroups'] ?? [], + $new['prefGroups'] + ); + } + + return $existing; + } + + /** + * Include PHP file in isolated scope and extract variables + * + * @param string $file File path + * @return array Extracted config ['_prefs' => [...], 'prefGroups' => [...]] + */ + private function includeFile(string $file): array + { + // Define variables that config files expect + $_prefs = []; + $prefGroups = []; + + // Include file + include $file; + + // Return extracted variables + return [ + '_prefs' => $_prefs, + 'prefGroups' => $prefGroups, + ]; + } +} diff --git a/src/Config/PrefsState.php b/src/Config/PrefsState.php new file mode 100644 index 00000000..6422ded3 --- /dev/null +++ b/src/Config/PrefsState.php @@ -0,0 +1,108 @@ +prefs[$name] ?? null; + } + + /** + * List all preference names + * + * @return array List of pref names + */ + public function listPrefs(): array + { + return array_keys($this->prefs); + } + + /** + * Get preference groups + * + * @return array Preference groups array + */ + public function getPrefGroups(): array + { + return $this->prefGroups; + } + + /** + * Get specific preference group + * + * @param string $name Group name + * @return array|null Group definition or null + */ + public function getPrefGroup(string $name): ?array + { + return $this->prefGroups[$name] ?? null; + } + + /** + * Check if preference exists + * + * @param string $name Pref name + * @return bool True if pref exists + */ + public function hasPref(string $name): bool + { + return isset($this->prefs[$name]); + } + + /** + * Get all prefs and groups as array (for legacy compatibility) + * + * @return array ['_prefs' => [...], 'prefGroups' => [...]] + */ + public function toArray(): array + { + return [ + '_prefs' => $this->prefs, + 'prefGroups' => $this->prefGroups, + ]; + } +} diff --git a/src/Config/RegistryConfigLoader.php b/src/Config/RegistryConfigLoader.php new file mode 100644 index 00000000..ca9e2d44 --- /dev/null +++ b/src/Config/RegistryConfigLoader.php @@ -0,0 +1,231 @@ +vhost = Vhost::from($this->vhost); + } + + /** + * Load global application registry + * + * Registry is global - no $app parameter + * + * @param string $file Config filename (default: 'registry.php') + * @return RegistryState Immutable registry state + */ + public function load(string $file = 'registry.php'): RegistryState + { + if ($this->cache === null) { + $applications = $this->loadFiles($file); + $this->cache = new RegistryState($applications); + } + + return $this->cache; + } + + /** + * Load specific layer for introspection + * + * @param string $layer Layer name ('vendor', 'base', 'snippets', 'local', 'vhost') + * @param string $file Config filename + * @return array Raw applications array from that layer + */ + public function loadLayer(string $layer, string $file = 'registry.php'): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/horde/'; + $vendorDir = $this->vendorBase . '/config/'; + + switch ($layer) { + case 'vendor': + $filePath = $vendorDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'base': + $filePath = $confDir . $file; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'snippets': + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (!is_dir($snippetsDir)) { + return []; + } + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets === false) { + return []; + } + sort($snippets); + $merged = []; + foreach ($snippets as $snippetFile) { + $loaded = $this->includeFile($snippetFile); + $merged = array_replace_recursive($merged, $loaded); + } + return $merged; + + case 'local': + $filePath = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + return file_exists($filePath) ? $this->includeFile($filePath) : []; + + case 'vhost': + if (!$this->vhost->isAvailable()) { + return []; + } + $vhostFilename = $this->vhost->getVhostFilename($file); + if (!$vhostFilename) { + return []; + } + $vhostFile = $confDir . $vhostFilename; + return file_exists($vhostFile) ? $this->includeFile($vhostFile) : []; + + default: + throw new RuntimeException("Unknown layer: $layer"); + } + } + + /** + * Clear cache (useful for testing) + */ + public function clearCache(): void + { + $this->cache = null; + } + + /** + * Load and merge registry files + * + * @param string $file Config filename + * @return array Merged applications array + */ + private function loadFiles(string $file): array + { + $pathInfo = pathinfo($file); + $confDir = $this->configBase . '/horde/'; + $vendorDir = $this->vendorBase . '/config/'; + + // Build file list + $files = []; + + // 1. Vendor defaults (factory defaults) + $files[] = $vendorDir . $file; + + // 2. Deployment base config + $files[] = $confDir . $file; + + // 3. Config snippets (registry.d/) + $snippetsDir = $confDir . $pathInfo['filename'] . '.d'; + if (is_dir($snippetsDir)) { + $snippets = glob($snippetsDir . '/*.php'); + if ($snippets !== false) { + sort($snippets); // Load in alphabetical order + $files = array_merge($files, $snippets); + } + } + + // 4. Local override + $localFile = $confDir . $pathInfo['filename'] . '.local.' . $pathInfo['extension']; + $files[] = $localFile; + + // Load files + $applications = []; + + foreach ($files as $filePath) { + if (file_exists($filePath)) { + // Include file in isolated scope + $loaded = $this->includeFile($filePath); + + // Merge applications + $applications = array_replace_recursive($applications, $loaded); + } + } + + // 5. VHost override (via Vhost object) + if ($this->vhost->isAvailable()) { + $vhostFilename = $this->vhost->getVhostFilename($file); + if ($vhostFilename) { + $vhostFile = $confDir . $vhostFilename; + if (file_exists($vhostFile)) { + $loaded = $this->includeFile($vhostFile); + $applications = array_replace_recursive($applications, $loaded); + } + } + } + + return $applications; + } + + /** + * Include PHP file in isolated scope with $this context + * + * Registry files expect $this->applications to be available + * + * @param string $file File path + * @return array Extracted applications array + */ + private function includeFile(string $file): array + { + // Create mock object with applications property + // Registry files assign to $this->applications + $mock = new class () { + public array $applications = []; + }; + + // Execute file with $this pointing to mock + $executeInContext = function ($file) use ($mock) { + // Bind closure to mock object so $this is available + $executor = function () use ($file) { + include $file; + }; + $boundExecutor = $executor->bindTo($mock, $mock); + $boundExecutor(); + }; + + $executeInContext($file); + + return $mock->applications; + } +} diff --git a/src/Config/RegistryState.php b/src/Config/RegistryState.php new file mode 100644 index 00000000..92070c0e --- /dev/null +++ b/src/Config/RegistryState.php @@ -0,0 +1,82 @@ +applications[$app] ?? null; + } + + /** + * List all application names + * + * @return array List of app names + */ + public function listApplications(): array + { + return array_keys($this->applications); + } + + /** + * Check if application exists + * + * @param string $app App name + * @return bool True if app exists in registry + */ + public function hasApplication(string $app): bool + { + return isset($this->applications[$app]); + } + + /** + * Get all applications as array (for legacy compatibility) + * + * @return array Raw applications array + */ + public function toArray(): array + { + return $this->applications; + } +} diff --git a/src/Config/State.php b/src/Config/State.php index ac90fcba..8422b5d6 100644 --- a/src/Config/State.php +++ b/src/Config/State.php @@ -1,12 +1,12 @@ + * @author Ralf Lang * @category Horde * @license http://www.horde.org/licenses/lgpl21 LGPL * @package Core @@ -15,17 +15,21 @@ namespace Horde\Core\Config; +use ArrayAccess; +use RuntimeException; + /** * Horde Config encapsulated in an object * * This is basically an injectable $GLOBALS['conf'] + * Provides read-only access with support for dot notation. * - * @author Ralf Lang + * @author Ralf Lang * @category Horde * @license http://www.horde.org/licenses/lgpl21 LGPL * @package Core */ -class State +class State implements ArrayAccess { protected $conf = []; @@ -51,11 +55,50 @@ public function __construct(?array $conf = null) ); } } + /** - * TODO: While it's probably wise NOT to implement ArrayAccess - * and keep objects of this class more or less static/readonly, - * We should have some OO way of accessing single config keys + * Get config value with support for dot notation + * + * @param string $key Config key (supports dot notation: 'admin_api.enabled') + * @param mixed $default Default value if not found + * @return mixed Config value */ + public function get(string $key, mixed $default = null): mixed + { + // Support dot notation: 'admin_api.enabled' + $keys = explode('.', $key); + $value = $this->conf; + + foreach ($keys as $k) { + if (!is_array($value) || !array_key_exists($k, $value)) { + return $default; + } + $value = $value[$k]; + } + + return $value; + } + + /** + * Check if key exists (supports dot notation) + * + * @param string $key Config key + * @return bool True if key exists + */ + public function has(string $key): bool + { + $keys = explode('.', $key); + $value = $this->conf; + + foreach ($keys as $k) { + if (!is_array($value) || !array_key_exists($k, $value)) { + return false; + } + $value = $value[$k]; + } + + return true; + } /** * Return the config array @@ -66,4 +109,26 @@ public function toArray(): array { return $this->conf; } + + // ArrayAccess implementation for backward compatibility + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->conf); + } + + #[\ReturnTypeWillChange] + public function offsetGet($offset): mixed + { + return $this->conf[$offset] ?? null; + } + + public function offsetSet($offset, $value): void + { + throw new RuntimeException('ConfigState is immutable'); + } + + public function offsetUnset($offset): void + { + throw new RuntimeException('ConfigState is immutable'); + } } diff --git a/src/Config/Vhost.php b/src/Config/Vhost.php new file mode 100644 index 00000000..2441ad95 --- /dev/null +++ b/src/Config/Vhost.php @@ -0,0 +1,117 @@ +hostname = $_SERVER['SERVER_NAME'] + ?? $_SERVER['HTTP_HOST'] + ?? null; + } else { + $this->hostname = $hostname; + } + } + + /** + * Get hostname + * + * @return string|null Hostname or null if not available + */ + public function getHostname(): ?string + { + return $this->hostname; + } + + /** + * Check if vhost is available + * + * @return bool True if hostname is available + */ + public function isAvailable(): bool + { + return $this->hostname !== null; + } + + /** + * Get vhost-specific filename + * + * Examples: + * - conf.php → conf-example.com.php + * - backends.local.php → backends-example.com.local.php + * - prefs.php → prefs-example.com.php + * + * @param string $basename Base filename (e.g., 'conf.php') + * @return string|null Vhost filename or null if vhost not available + */ + public function getVhostFilename(string $basename): ?string + { + if (!$this->isAvailable()) { + return null; + } + + // Handle compound extensions like 'backends.local.php' + // Pattern: filename.ext1.ext2 → filename-vhost.ext1.ext2 + $parts = explode('.', $basename); + + if (count($parts) < 2) { + // No extension + return $basename . '-' . $this->hostname; + } + + // Insert vhost after first part (filename) + $filename = array_shift($parts); + $extensions = implode('.', $parts); + + return $filename . '-' . $this->hostname . '.' . $extensions; + } + + /** + * Create Vhost from string (for union type support) + * + * @param Vhost|string $vhost Vhost object or hostname string + * @return Vhost + */ + public static function from(Vhost|string $vhost): Vhost + { + if ($vhost instanceof Vhost) { + return $vhost; + } + return new Vhost($vhost); + } +} diff --git a/src/Controller/ResponsiveControllerTrait.php b/src/Controller/ResponsiveControllerTrait.php index 740d4fcc..bce1c914 100644 --- a/src/Controller/ResponsiveControllerTrait.php +++ b/src/Controller/ResponsiveControllerTrait.php @@ -1,4 +1,5 @@ buildUrl($path, $params); }; diff --git a/src/Middleware/AppRouter.php b/src/Middleware/AppRouter.php index 4e1b3e5f..3b527a6b 100644 --- a/src/Middleware/AppRouter.php +++ b/src/Middleware/AppRouter.php @@ -21,6 +21,11 @@ use Horde_String; use Psr\Http\Message\ResponseFactoryInterface; use Horde\Exception\HordeException; +use Horde\Core\Config\ConfigLoader; +use Horde\Core\Config\BackendConfigLoader; +use Horde\Core\Config\PrefsConfigLoader; +use Horde\Core\Config\RegistryConfigLoader; +use Horde\Core\Config\Vhost; /** * AppRouter middleware @@ -65,6 +70,49 @@ public function __construct(Horde_Registry $registry, Mapper $mapper, Horde_Inje */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + // Setup ConfigLoader in DI container if not already present + if (!$this->injector->has(ConfigLoader::class)) { + if (!defined('HORDE_CONFIG_BASE')) { + throw new Exception('HORDE_CONFIG_BASE not defined'); + } + $this->injector->setInstance( + ConfigLoader::class, + new ConfigLoader(HORDE_CONFIG_BASE, new Vhost()) // Auto-detect vhost + ); + } + + // Setup BackendConfigLoader in DI container if not already present + if (!$this->injector->has(BackendConfigLoader::class)) { + if (!defined('HORDE_CONFIG_BASE')) { + throw new Exception('HORDE_CONFIG_BASE not defined'); + } + // Vendor base = composer vendor/horde/ directory + $vendorBase = dirname(__DIR__, 4) . '/vendor/horde'; + $this->injector->setInstance( + BackendConfigLoader::class, + new BackendConfigLoader(HORDE_CONFIG_BASE, $vendorBase, new Vhost()) // Auto-detect vhost + ); + } + + // Setup PrefsConfigLoader in DI container if not already present + if (!$this->injector->has(PrefsConfigLoader::class)) { + $vendorBase = dirname(__DIR__, 4) . '/vendor/horde'; + $this->injector->setInstance( + PrefsConfigLoader::class, + new PrefsConfigLoader(HORDE_CONFIG_BASE, $vendorBase, new Vhost()) // Auto-detect vhost + ); + } + + // Setup RegistryConfigLoader in DI container if not already present + if (!$this->injector->has(RegistryConfigLoader::class)) { + // Vendor base = horde base app directory + $vendorBase = dirname(__DIR__, 4) . '/vendor/horde/horde'; + $this->injector->setInstance( + RegistryConfigLoader::class, + new RegistryConfigLoader(HORDE_CONFIG_BASE, $vendorBase, new Vhost()) // Auto-detect vhost + ); + } + $app = $request->getAttribute('app'); $prefix = $request->getAttribute('routerPrefix'); if (is_null($prefix)) { @@ -119,9 +167,28 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // Empty array means NO more middleware besides controller // unset stack means DEFAULT middleware stack $stack = $match['stack'] ?? $defaultStack; + + // DEBUG - Use web-writable var directory + $varDir = dirname($fileroot, 2) . '/var'; + if (!is_dir($varDir)) { + $varDir = '/tmp'; + } + $debugLog = $varDir . '/approuter-debug.log'; + file_put_contents($debugLog, "=== AppRouter Debug ===\n", FILE_APPEND); + file_put_contents($debugLog, 'Time: ' . date('Y-m-d H:i:s') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'Route: ' . ($match['name'] ?? 'UNNAMED') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'Controller: ' . ($match['controller'] ?? 'NONE') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'HordeAuthType: ' . ($match['HordeAuthType'] ?? 'NOT SET') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'stack isset: ' . (isset($match['stack']) ? 'YES' : 'NO') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'stack value: ' . json_encode($match['stack'] ?? 'NOT SET') . "\n", FILE_APPEND); + file_put_contents($debugLog, 'Using stack: ' . json_encode($stack) . "\n", FILE_APPEND); + file_put_contents($debugLog, 'Stack count: ' . count($stack) . "\n", FILE_APPEND); + foreach ($stack as $middleware) { + file_put_contents($debugLog, 'Adding middleware: ' . $middleware . "\n", FILE_APPEND); $handler->addMiddleware($this->injector->get($middleware)); } + file_put_contents($debugLog, "=== End AppRouter Debug ===\n\n", FILE_APPEND); // Controller is a single DI key for either a HandlerInterface, MiddlewareInterface or a Horde_Controller $controllerName = $match['controller'] ?? ''; diff --git a/src/View/ResponsiveTopbar.php b/src/View/ResponsiveTopbar.php index 46b2546b..cb7b12ec 100644 --- a/src/View/ResponsiveTopbar.php +++ b/src/View/ResponsiveTopbar.php @@ -1,4 +1,5 @@ tempDir = sys_get_temp_dir() . '/horde-backend-test-' . uniqid(); + $this->vendorDir = $this->tempDir . '/vendor/horde'; + $this->configDir = $this->tempDir . '/config'; + + mkdir($this->vendorDir . '/passwd/config', 0755, true); + mkdir($this->configDir . '/passwd', 0755, true); + + $this->loader = new BackendConfigLoader($this->configDir, $this->vendorDir); + } + + protected function tearDown(): void + { + // Clean up temp directory + $this->removeDirectory($this->tempDir); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + public function testLoadVendorDefaults(): void + { + // Create vendor defaults + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + true, + 'name' => 'Horde SQL', + 'driver' => 'Sql', +]; +$backends['ldap'] = [ + 'disabled' => true, + 'name' => 'LDAP', + 'driver' => 'Ldap', +]; +PHP + ); + + $state = $this->loader->load('passwd'); + + $this->assertInstanceOf(BackendState::class, $state); + $this->assertTrue($state->hasBackend('hordesql')); + $this->assertTrue($state->hasBackend('ldap')); + } + + public function testLocalOverride(): void + { + // Vendor defaults + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + true, + 'name' => 'Horde SQL', + 'driver' => 'Sql', + 'params' => ['table' => 'default_table'], +]; +PHP + ); + + // Local override + $localFile = $this->configDir . '/passwd/backends.local.php'; + file_put_contents($localFile, <<<'PHP' + false, + 'params' => ['table' => 'custom_table'], +]; +PHP + ); + + $state = $this->loader->load('passwd'); + $backend = $state->getBackend('hordesql'); + + // Verify override + $this->assertFalse($backend['disabled']); + $this->assertEquals('custom_table', $backend['params']['table']); + // Verify vendor defaults still present + $this->assertEquals('Horde SQL', $backend['name']); + $this->assertEquals('Sql', $backend['driver']); + } + + public function testSnippets(): void + { + // Vendor defaults + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + true, + 'name' => 'Horde SQL', +]; +PHP + ); + + // Create snippets directory + mkdir($this->configDir . '/passwd/backends.d', 0755, true); + + // Snippet 1 + $snippet1 = $this->configDir . '/passwd/backends.d/01-ldap.php'; + file_put_contents($snippet1, <<<'PHP' + false, + 'name' => 'LDAP Server', +]; +PHP + ); + + // Snippet 2 + $snippet2 = $this->configDir . '/passwd/backends.d/02-poppassd.php'; + file_put_contents($snippet2, <<<'PHP' + false, + 'name' => 'Poppassd', +]; +PHP + ); + + $state = $this->loader->load('passwd'); + + $this->assertTrue($state->hasBackend('hordesql')); + $this->assertTrue($state->hasBackend('ldap')); + $this->assertTrue($state->hasBackend('poppassd')); + } + + public function testCaching(): void + { + // Vendor defaults + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + 'Horde SQL', +]; +PHP + ); + + // First load + $state1 = $this->loader->load('passwd'); + + // Second load (should return cached) + $state2 = $this->loader->load('passwd'); + + $this->assertSame($state1, $state2); + } + + public function testClearCache(): void + { + // Vendor defaults + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + 'Horde SQL', +]; +PHP + ); + + // First load + $state1 = $this->loader->load('passwd'); + + // Clear cache + $this->loader->clearCache(); + + // Second load (should not be cached) + $state2 = $this->loader->load('passwd'); + + $this->assertNotSame($state1, $state2); + } + + public function testLoadLayerVendor(): void + { + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + 'Vendor SQL', +]; +PHP + ); + + $localFile = $this->configDir . '/passwd/backends.local.php'; + file_put_contents($localFile, <<<'PHP' + 'Local SQL', +]; +PHP + ); + + $vendorLayer = $this->loader->loadLayer('passwd', 'vendor'); + + $this->assertEquals('Vendor SQL', $vendorLayer['hordesql']['name']); + } + + public function testLoadLayerLocal(): void + { + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + 'Vendor SQL', +]; +PHP + ); + + $localFile = $this->configDir . '/passwd/backends.local.php'; + file_put_contents($localFile, <<<'PHP' + 'Local SQL', +]; +PHP + ); + + $localLayer = $this->loader->loadLayer('passwd', 'local'); + + $this->assertEquals('Local SQL', $localLayer['hordesql']['name']); + } + + public function testDisabledFiltering(): void + { + $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($vendorFile, <<<'PHP' + false, + 'name' => 'Enabled', +]; +$backends['disabled_backend'] = [ + 'disabled' => true, + 'name' => 'Disabled', +]; +$backends['no_flag_backend'] = [ + 'name' => 'No Flag', +]; +PHP + ); + + $state = $this->loader->load('passwd'); + + // By default, disabled backends excluded + $enabled = $state->listBackends(false); + $this->assertArrayHasKey('enabled_backend', $enabled); + $this->assertArrayNotHasKey('disabled_backend', $enabled); + $this->assertArrayHasKey('no_flag_backend', $enabled); + + // With flag, disabled included + $all = $state->listBackends(true); + $this->assertArrayHasKey('enabled_backend', $all); + $this->assertArrayHasKey('disabled_backend', $all); + $this->assertArrayHasKey('no_flag_backend', $all); + } + + public function testMultipleApps(): void + { + // Setup passwd app (already exists from setUp) + $passwdFile = $this->vendorDir . '/passwd/config/backends.php'; + file_put_contents($passwdFile, <<<'PHP' + 'Passwd Backend', +]; +PHP + ); + + // Setup imp app + mkdir($this->vendorDir . '/imp/config', 0755, true); + mkdir($this->configDir . '/imp', 0755, true); + $impFile = $this->vendorDir . '/imp/config/backends.php'; + file_put_contents($impFile, <<<'PHP' + 'IMP Backend', +]; +PHP + ); + + $passwdState = $this->loader->load('passwd'); + $impState = $this->loader->load('imp'); + + $this->assertTrue($passwdState->hasBackend('passwd_backend')); + $this->assertFalse($passwdState->hasBackend('imp_backend')); + + $this->assertTrue($impState->hasBackend('imp_backend')); + $this->assertFalse($impState->hasBackend('passwd_backend')); + } +} diff --git a/test/Config/BackendStateTest.php b/test/Config/BackendStateTest.php new file mode 100644 index 00000000..118f9f22 --- /dev/null +++ b/test/Config/BackendStateTest.php @@ -0,0 +1,116 @@ + [ + 'disabled' => false, + 'name' => 'Horde SQL', + 'driver' => 'Sql', + 'params' => ['table' => 'users'], + ], + 'ldap' => [ + 'disabled' => true, + 'name' => 'LDAP Server', + 'driver' => 'Ldap', + 'params' => ['host' => 'localhost'], + ], + 'poppassd' => [ + 'name' => 'Poppassd', + 'driver' => 'Poppassd', + 'params' => ['port' => 106], + ], + ]; + + $this->state = new BackendState($backends); + } + + public function testGetBackend(): void + { + $backend = $this->state->getBackend('hordesql'); + + $this->assertIsArray($backend); + $this->assertEquals('Horde SQL', $backend['name']); + $this->assertEquals('Sql', $backend['driver']); + } + + public function testGetNonexistentBackend(): void + { + $backend = $this->state->getBackend('nonexistent'); + + $this->assertNull($backend); + } + + public function testListBackendsExcludesDisabled(): void + { + $backends = $this->state->listBackends(false); + + $this->assertCount(2, $backends); + $this->assertArrayHasKey('hordesql', $backends); + $this->assertArrayHasKey('poppassd', $backends); + $this->assertArrayNotHasKey('ldap', $backends); + } + + public function testListBackendsIncludesDisabled(): void + { + $backends = $this->state->listBackends(true); + + $this->assertCount(3, $backends); + $this->assertArrayHasKey('hordesql', $backends); + $this->assertArrayHasKey('ldap', $backends); + $this->assertArrayHasKey('poppassd', $backends); + } + + public function testListBackendsDefaultExcludesDisabled(): void + { + $backends = $this->state->listBackends(); + + $this->assertCount(2, $backends); + $this->assertArrayNotHasKey('ldap', $backends); + } + + public function testHasBackend(): void + { + $this->assertTrue($this->state->hasBackend('hordesql')); + $this->assertTrue($this->state->hasBackend('ldap')); + $this->assertFalse($this->state->hasBackend('nonexistent')); + } + + public function testToArray(): void + { + $backends = $this->state->toArray(); + + $this->assertIsArray($backends); + $this->assertCount(3, $backends); + $this->assertArrayHasKey('hordesql', $backends); + $this->assertArrayHasKey('ldap', $backends); + $this->assertArrayHasKey('poppassd', $backends); + } + + public function testBackendWithoutDisabledFlagIsIncluded(): void + { + // Backend without 'disabled' key should be included (not disabled) + $backends = $this->state->listBackends(false); + + $this->assertArrayHasKey('poppassd', $backends); + } +} diff --git a/test/Config/PrefsStateTest.php b/test/Config/PrefsStateTest.php new file mode 100644 index 00000000..ba26d0fa --- /dev/null +++ b/test/Config/PrefsStateTest.php @@ -0,0 +1,128 @@ + [ + 'value' => 'a:0:{}', + 'type' => 'multienum', + 'desc' => 'Select address books', + ], + 'name_format' => [ + 'value' => 'last_first', + 'type' => 'enum', + 'enum' => ['last_first' => 'Last, First', 'first_last' => 'First Last'], + ], + ]; + + $prefGroups = [ + 'addressbooks' => [ + 'column' => 'Address Books', + 'label' => 'Address Books', + 'members' => ['sync_books'], + ], + 'format' => [ + 'column' => 'Display', + 'label' => 'Name Format', + 'members' => ['name_format'], + ], + ]; + + $this->state = new PrefsState($prefs, $prefGroups); + } + + public function testGetPref(): void + { + $pref = $this->state->getPref('sync_books'); + + $this->assertIsArray($pref); + $this->assertEquals('a:0:{}', $pref['value']); + $this->assertEquals('multienum', $pref['type']); + } + + public function testGetNonexistentPref(): void + { + $pref = $this->state->getPref('nonexistent'); + + $this->assertNull($pref); + } + + public function testListPrefs(): void + { + $prefs = $this->state->listPrefs(); + + $this->assertCount(2, $prefs); + $this->assertContains('sync_books', $prefs); + $this->assertContains('name_format', $prefs); + } + + public function testGetPrefGroups(): void + { + $groups = $this->state->getPrefGroups(); + + $this->assertCount(2, $groups); + $this->assertArrayHasKey('addressbooks', $groups); + $this->assertArrayHasKey('format', $groups); + } + + public function testGetPrefGroup(): void + { + $group = $this->state->getPrefGroup('addressbooks'); + + $this->assertIsArray($group); + $this->assertEquals('Address Books', $group['column']); + $this->assertContains('sync_books', $group['members']); + } + + public function testGetNonexistentPrefGroup(): void + { + $group = $this->state->getPrefGroup('nonexistent'); + + $this->assertNull($group); + } + + public function testHasPref(): void + { + $this->assertTrue($this->state->hasPref('sync_books')); + $this->assertTrue($this->state->hasPref('name_format')); + $this->assertFalse($this->state->hasPref('nonexistent')); + } + + public function testToArray(): void + { + $array = $this->state->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('_prefs', $array); + $this->assertArrayHasKey('prefGroups', $array); + $this->assertCount(2, $array['_prefs']); + $this->assertCount(2, $array['prefGroups']); + } + + public function testEmptyPrefGroups(): void + { + $state = new PrefsState(['test' => ['value' => 'foo']]); + + $this->assertEquals([], $state->getPrefGroups()); + $this->assertNull($state->getPrefGroup('any')); + } +} diff --git a/test/Config/RegistryStateTest.php b/test/Config/RegistryStateTest.php new file mode 100644 index 00000000..ebabcbbd --- /dev/null +++ b/test/Config/RegistryStateTest.php @@ -0,0 +1,86 @@ + [ + 'name' => 'Horde', + 'initial_page' => 'services/portal/index.php', + 'provides' => 'horde', + ], + 'turba' => [ + 'name' => 'Address Book', + 'provides' => ['contacts', 'clients/getClientSource'], + ], + 'imp' => [ + 'name' => 'Mail', + 'provides' => ['mail', 'contacts/favouriteRecipients'], + ], + ]; + + $this->state = new RegistryState($applications); + } + + public function testGetApplication(): void + { + $app = $this->state->getApplication('turba'); + + $this->assertIsArray($app); + $this->assertEquals('Address Book', $app['name']); + $this->assertIsArray($app['provides']); + } + + public function testGetNonexistentApplication(): void + { + $app = $this->state->getApplication('nonexistent'); + + $this->assertNull($app); + } + + public function testListApplications(): void + { + $apps = $this->state->listApplications(); + + $this->assertCount(3, $apps); + $this->assertContains('horde', $apps); + $this->assertContains('turba', $apps); + $this->assertContains('imp', $apps); + } + + public function testHasApplication(): void + { + $this->assertTrue($this->state->hasApplication('horde')); + $this->assertTrue($this->state->hasApplication('turba')); + $this->assertFalse($this->state->hasApplication('nonexistent')); + } + + public function testToArray(): void + { + $array = $this->state->toArray(); + + $this->assertIsArray($array); + $this->assertCount(3, $array); + $this->assertArrayHasKey('horde', $array); + $this->assertArrayHasKey('turba', $array); + $this->assertArrayHasKey('imp', $array); + } +} diff --git a/test/Config/VhostTest.php b/test/Config/VhostTest.php new file mode 100644 index 00000000..375d1dac --- /dev/null +++ b/test/Config/VhostTest.php @@ -0,0 +1,141 @@ +assertEquals('example.com', $vhost->getHostname()); + $this->assertTrue($vhost->isAvailable()); + } + + public function testNullHostname(): void + { + // Save original $_SERVER values + $originalServerName = $_SERVER['SERVER_NAME'] ?? null; + $originalHttpHost = $_SERVER['HTTP_HOST'] ?? null; + + // Clear $_SERVER + unset($_SERVER['SERVER_NAME']); + unset($_SERVER['HTTP_HOST']); + + $vhost = new Vhost(null); + + $this->assertNull($vhost->getHostname()); + $this->assertFalse($vhost->isAvailable()); + + // Restore $_SERVER + if ($originalServerName !== null) { + $_SERVER['SERVER_NAME'] = $originalServerName; + } + if ($originalHttpHost !== null) { + $_SERVER['HTTP_HOST'] = $originalHttpHost; + } + } + + public function testAutoDetectServerName(): void + { + // Save original + $originalServerName = $_SERVER['SERVER_NAME'] ?? null; + + $_SERVER['SERVER_NAME'] = 'mail.example.com'; + + $vhost = new Vhost(null); + + $this->assertEquals('mail.example.com', $vhost->getHostname()); + $this->assertTrue($vhost->isAvailable()); + + // Restore + if ($originalServerName !== null) { + $_SERVER['SERVER_NAME'] = $originalServerName; + } else { + unset($_SERVER['SERVER_NAME']); + } + } + + public function testAutoDetectHttpHost(): void + { + // Save original + $originalServerName = $_SERVER['SERVER_NAME'] ?? null; + $originalHttpHost = $_SERVER['HTTP_HOST'] ?? null; + + // Clear SERVER_NAME, set HTTP_HOST + unset($_SERVER['SERVER_NAME']); + $_SERVER['HTTP_HOST'] = 'web.example.com'; + + $vhost = new Vhost(null); + + $this->assertEquals('web.example.com', $vhost->getHostname()); + $this->assertTrue($vhost->isAvailable()); + + // Restore + if ($originalServerName !== null) { + $_SERVER['SERVER_NAME'] = $originalServerName; + } + if ($originalHttpHost !== null) { + $_SERVER['HTTP_HOST'] = $originalHttpHost; + } else { + unset($_SERVER['HTTP_HOST']); + } + } + + public function testGetVhostFilename(): void + { + $vhost = new Vhost('example.com'); + + $this->assertEquals('conf-example.com.php', $vhost->getVhostFilename('conf.php')); + $this->assertEquals('backends-example.com.php', $vhost->getVhostFilename('backends.php')); + $this->assertEquals('prefs-example.com.local.php', $vhost->getVhostFilename('prefs.local.php')); + } + + public function testGetVhostFilenameWhenNotAvailable(): void + { + $vhost = new Vhost(null); + + $this->assertNull($vhost->getVhostFilename('conf.php')); + } + + public function testFromVhostObject(): void + { + $original = new Vhost('example.com'); + $result = Vhost::from($original); + + $this->assertSame($original, $result); + } + + public function testFromString(): void + { + $vhost = Vhost::from('example.com'); + + $this->assertInstanceOf(Vhost::class, $vhost); + $this->assertEquals('example.com', $vhost->getHostname()); + } + + public function testDefaultConstructorValue(): void + { + // Test that default parameter works + $loader = function(Vhost|string $vhost = 'localhost') { + return Vhost::from($vhost); + }; + + $vhost = $loader(); + + $this->assertEquals('localhost', $vhost->getHostname()); + } +}