diff --git a/.gitignore b/.gitignore index dcbee11a..55a09d08 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,8 @@ run-tests.log /test/*/*/*/*.exp /test/*/*/*/*.log /test/*/*/*/*.out - +## composer plugin's var/config tree shall not be committed. +/var /vendor /composer.lock /web diff --git a/composer.json b/composer.json index 7a7d5547..db272ba6 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,8 @@ "horde/util": "^3 || dev-FRAMEWORK_6_0", "horde/view": "^3 || dev-FRAMEWORK_6_0", "php81_bc/strftime": "^0.7", - "ext-session": "*" + "ext-session": "*", + "horde/ldap": "^3.0@dev" }, "require-dev": { "horde/test": "^3 || dev-FRAMEWORK_6_0", @@ -82,6 +83,7 @@ "horde/vfs": "^3 || dev-FRAMEWORK_6_0" }, "suggest": { + "horde/ldap": "For LDAP connection support", "mikepultz/netdns2": "For DNS resolver functionality (replaces pear/net_dns2)", "pear/text_captcha": "*", "pear/text_figlet": "*", @@ -104,11 +106,14 @@ } }, "config": { - "allow-plugins": {} + "allow-plugins": { + "horde/horde-installer-plugin": true + } }, "extra": { "branch-alias": { "dev-FRAMEWORK_6_0": "3.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/lib/Horde/Registry.php b/lib/Horde/Registry.php index 3c0cfdb9..009be813 100644 --- a/lib/Horde/Registry.php +++ b/lib/Horde/Registry.php @@ -458,6 +458,16 @@ public function __construct($session_flags = 0, array $args = []) Horde\Log\Logger::class => Horde\Core\Factory\LoggerFactory::class, 'Horde\\Horde\\Service\\JwtService' => 'Horde\\Horde\\Factory\\JwtServiceFactory', 'Horde\\Horde\\Service\\AuthenticationService' => 'Horde\\Horde\\Factory\\AuthenticationServiceFactory', + 'Horde\\Core\\Config\\ConfigLoader' => 'Horde\\Core\\Factory\\ConfigLoaderFactory', + 'Horde\\Core\\Service\\HordeDbService' => 'Horde\\Core\\Factory\\DbServiceFactory', + 'Horde\\Core\\Service\\PrefsService' => 'Horde\\Core\\Factory\\PrefsServiceFactory', + 'Horde\\Core\\Service\\IdentityService' => 'Horde\\Core\\Factory\\IdentityServiceFactory', + 'Horde\\Core\\Service\\GroupService' => 'Horde\\Core\\Factory\\GroupServiceFactory', + 'Horde\\Core\\Config\\RegistryConfigLoader' => 'Horde\\Core\\Factory\\RegistryConfigLoaderFactory', + 'Horde\\Core\\Service\\ApplicationService' => 'Horde\\Core\\Factory\\ApplicationServiceFactory', + 'Horde\\Core\\Auth\\AuthService' => 'Horde\\Core\\Factory\\AuthServiceFactory', + 'Horde\\Core\\Service\\HordeLdapService' => 'Horde\\Core\\Factory\\HordeLdapServiceFactory', + 'Horde\\Core\\Service\\PermissionService' => 'Horde\\Core\\Factory\\PermissionServiceFactory', ]; /* Define implementations. */ diff --git a/src/Auth/AuthNotSupportedException.php b/src/Auth/AuthNotSupportedException.php new file mode 100644 index 00000000..1f7e4793 --- /dev/null +++ b/src/Auth/AuthNotSupportedException.php @@ -0,0 +1,34 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Auth; + +use RuntimeException; + +/** + * Exception thrown when auth backend doesn't support an operation + * + * Used to distinguish unsupported operations (e.g., listUsers() on + * auth backends without listing capability) from actual errors. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class AuthNotSupportedException extends RuntimeException +{ +} diff --git a/src/Auth/AuthService.php b/src/Auth/AuthService.php new file mode 100644 index 00000000..82731035 --- /dev/null +++ b/src/Auth/AuthService.php @@ -0,0 +1,197 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Auth; + +use Horde_Auth_Base; +use Horde_Auth_Exception; + +/** + * Modern wrapper for Horde_Auth_Base with proper dependency injection + * + * Provides capability detection, consistent API, and eliminates + * reliance on global state in modern controllers. + * + * Wraps any Horde_Auth_Base implementation (Horde_Core_Auth_Application, + * Horde_Auth_Sql, Horde_Auth_Ldap, etc.) + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class AuthService +{ + /** + * Constructor + * + * @param Horde_Auth_Base $auth Legacy auth backend instance + */ + public function __construct( + private Horde_Auth_Base $auth + ) { + } + + /** + * Check if backend supports user listing + * + * Tests capability by attempting to call listUsers() and catching + * the "Unsupported" exception thrown by base implementation. + * + * @return bool True if backend supports listing + */ + public function supportsListing(): bool + { + try { + $this->auth->listUsers(); + return true; + } catch (Horde_Auth_Exception $e) { + if (str_contains($e->getMessage(), 'Unsupported')) { + return false; + } + // Re-throw unexpected errors (e.g., connection failures) + throw $e; + } + } + + /** + * List all users + * + * Returns array of usernames from auth backend. + * + * @param bool $sort Sort the users alphabetically + * @return string[] Array of usernames + * @throws AuthNotSupportedException If backend doesn't support listing + * @throws Horde_Auth_Exception On backend errors + */ + public function listUsers(bool $sort = false): array + { + try { + return $this->auth->listUsers($sort); + } catch (Horde_Auth_Exception $e) { + if (str_contains($e->getMessage(), 'Unsupported')) { + throw new AuthNotSupportedException( + 'Auth backend does not support user listing' + ); + } + throw $e; + } + } + + /** + * Check if user exists + * + * @param string $username Username to check + * @return bool True if user exists + * @throws Horde_Auth_Exception On backend errors + */ + public function exists(string $username): bool + { + return $this->auth->exists($username); + } + + /** + * Update user password + * + * @param string $username Username to update + * @param string $newPassword New password + * @return void + * @throws Horde_Auth_Exception On backend errors or if user doesn't exist + */ + public function updatePassword(string $username, string $newPassword): void + { + $this->auth->updateUser($username, $username, [ + 'password' => $newPassword, + ]); + } + + /** + * Create new user + * + * @param string $username Username for new user + * @param array $credentials User credentials and attributes + * Required: 'password' + * Optional: 'email', 'full_name', etc. + * @return void + * @throws Horde_Auth_Exception On backend errors or if user exists + */ + public function createUser(string $username, array $credentials): void + { + $this->auth->addUser($username, $credentials); + } + + /** + * Delete user + * + * @param string $username Username to delete + * @return void + * @throws Horde_Auth_Exception On backend errors + */ + public function deleteUser(string $username): void + { + $this->auth->removeUser($username); + } + + /** + * Authenticate user credentials + * + * @param string $username Username to authenticate + * @param string $password Password to verify + * @return bool True if credentials valid + * @throws Horde_Auth_Exception On backend errors + */ + public function authenticate(string $username, string $password): bool + { + return $this->auth->authenticate($username, [ + 'password' => $password, + ]); + } + + /** + * Search users by substring + * + * @param string $search Search term + * @return string[] Array of matching usernames + * @throws AuthNotSupportedException If backend doesn't support searching + * @throws Horde_Auth_Exception On backend errors + */ + public function searchUsers(string $search): array + { + try { + return $this->auth->searchUsers($search); + } catch (Horde_Auth_Exception $e) { + if (str_contains($e->getMessage(), 'Unsupported')) { + throw new AuthNotSupportedException( + 'Auth backend does not support user searching' + ); + } + throw $e; + } + } + + /** + * Get underlying auth backend instance + * + * Provides escape hatch for operations not yet wrapped by this service. + * Use sparingly - prefer adding methods to AuthService instead. + * + * @return Horde_Auth_Base + */ + public function getBackend(): Horde_Auth_Base + { + return $this->auth; + } +} diff --git a/src/Factory/ApplicationServiceFactory.php b/src/Factory/ApplicationServiceFactory.php new file mode 100644 index 00000000..c5f23657 --- /dev/null +++ b/src/Factory/ApplicationServiceFactory.php @@ -0,0 +1,38 @@ +getInstance(RegistryConfigLoader::class); + return new ApplicationService($registryLoader); + } +} diff --git a/src/Factory/AuthServiceFactory.php b/src/Factory/AuthServiceFactory.php new file mode 100644 index 00000000..127ed443 --- /dev/null +++ b/src/Factory/AuthServiceFactory.php @@ -0,0 +1,125 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Auth\AuthService; +use Horde\Core\Service\HordeDbService; +use Horde\Core\Config\ConfigLoader; +use Horde_Auth_Sql; +use Horde_Core_Auth_Application; +use Horde_Injector; +use Horde_Log_Logger; +use RuntimeException; + +/** + * Factory for creating AuthService with proper backend + * + * Creates auth service from configuration without globals. + * Mirrors legacy Horde_Core_Factory_Auth behavior: + * - Returns Horde_Core_Auth_Application wrapping base driver + * - For 'horde' app: creates base driver (Horde_Auth_Sql, etc) + * - Wraps in Application auth for hooks and app-specific features + * + * Currently implemented: + * - 'sql': SQL authentication (Horde_Auth_Sql) + * - 'auto': Defaults to SQL + * + * TODO: Implement additional drivers as needed: + * - 'ldap': LDAP authentication (Horde_Core_Auth_Ldap) + * - 'msad': Microsoft Active Directory (Horde_Core_Auth_Msad) + * - 'composite': Multi-backend auth (Horde_Core_Auth_Composite) + * - 'application': App-specific auth (other apps, not horde) + * - 'shibboleth', 'x509', 'imsp', etc. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class AuthServiceFactory +{ + /** + * Create AuthService instance + * + * Returns Horde_Core_Auth_Application wrapping the configured + * base auth driver, matching legacy factory behavior. + * + * @param Horde_Injector $injector Dependency injector + * @return AuthService Auth service with configured backend + * @throws RuntimeException If driver unsupported + */ + public function create(Horde_Injector $injector): AuthService + { + $loader = $injector->getInstance(ConfigLoader::class); + $state = $loader->load('horde'); + + $driver = $state->get('auth.driver', 'auto'); + $params = $state->get('auth.params', []); + + // Get dependencies + $dbService = $injector->getInstance(HordeDbService::class); + $logger = $injector->getInstance(Horde_Log_Logger::class); + + // Create base driver based on config + $baseDriver = match ($driver) { + 'sql' => $this->createSqlBackend($params, $dbService, $logger), + 'auto' => $this->createSqlBackend($params, $dbService, $logger), + default => throw new RuntimeException("Unsupported auth driver: $driver (TODO: implement)"), + }; + + // Wrap in Horde_Core_Auth_Application (matches legacy factory) + $authApp = new Horde_Core_Auth_Application([ + 'app' => 'horde', + 'base' => $baseDriver, + 'logger' => $logger, + ]); + + return new AuthService($authApp); + } + + /** + * Create SQL authentication backend + * + * Matches legacy factory behavior (Core/Factory/Auth.php lines 169-173): + * - Gets DB adapter from Horde_Core_Factory_Db + * - Creates Horde_Auth_Sql with adapter and params + * - Adds logger and default_user from registry + * + * @param array $params Auth configuration parameters + * @param HordeDbService $dbService Database service + * @param Horde_Log_Logger $logger Logger instance + * @return Horde_Auth_Sql SQL auth backend instance + */ + private function createSqlBackend( + array $params, + HordeDbService $dbService, + Horde_Log_Logger $logger + ): Horde_Auth_Sql { + $authParams = [ + 'db' => $dbService->getAdapter(), + 'table' => $params['table'] ?? 'horde_users', + 'username_field' => $params['username_field'] ?? 'user_uid', + 'password_field' => $params['password_field'] ?? 'user_pass', + 'encryption' => $params['encryption'] ?? 'ssha', + 'show_encryption' => $params['show_encryption'] ?? false, + 'logger' => $logger, + // Note: default_user and count_bad_logins/login_block handled by Application wrapper + ]; + + return new Horde_Auth_Sql($authParams); + } +} diff --git a/src/Factory/ConfigLoaderFactory.php b/src/Factory/ConfigLoaderFactory.php new file mode 100644 index 00000000..a03b3bfd --- /dev/null +++ b/src/Factory/ConfigLoaderFactory.php @@ -0,0 +1,45 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Config\ConfigLoader; +use Horde\Core\Config\Vhost; +use Horde_Injector; + +/** + * Factory for ConfigLoader service + * + * Creates the global multi-app configuration loader. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ConfigLoaderFactory +{ + /** + * Create ConfigLoader instance + * + * @param Horde_Injector $injector Dependency injector + * @return ConfigLoader Global config loader for all apps + */ + public function create(Horde_Injector $injector): ConfigLoader + { + return new ConfigLoader(HORDE_CONFIG_BASE, new Vhost()); + } +} diff --git a/src/Factory/DbServiceFactory.php b/src/Factory/DbServiceFactory.php new file mode 100644 index 00000000..17c24d1d --- /dev/null +++ b/src/Factory/DbServiceFactory.php @@ -0,0 +1,212 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\StandardHordeDbService; +use Horde\Core\Config\ConfigLoader; +use Horde_Db_Adapter_Mysqli; +use Horde_Db_Adapter_Pdo_Mysql; +use Horde_Db_Adapter_Pdo_Pgsql; +use Horde_Db_Adapter_Pdo_Sqlite; +use Horde_Injector; +use InvalidArgumentException; + +/** + * Factory for creating HordeDbService from configuration + * + * Creates database adapter instances based on configuration + * without relying on global state or legacy factories. + * + * Supports connection pooling - services with identical configuration + * share the same adapter instance. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class DbServiceFactory +{ + /** + * Connection pool indexed by config signature + * + * @var array + */ + private array $adapters = []; + + /** + * Create database service from configuration + * + * @param Horde_Injector $injector Dependency injector + * @param string $serviceId Service identifier ('horde', 'horde:perms', etc.) + * @return StandardHordeDbService Database service instance + * @throws InvalidArgumentException If phptype unsupported + */ + public function create(Horde_Injector $injector, string $serviceId = 'horde'): StandardHordeDbService + { + $loader = $injector->getInstance(ConfigLoader::class); + $state = $loader->load('horde'); + + // Resolve config for this service + $sqlConfig = $this->resolveConfig($state, $serviceId); + + // Check if explicitly using horde connection + if (($sqlConfig['driverconfig'] ?? null) === 'horde' && $serviceId !== 'horde') { + // Recursive: use horde's connection + return $this->create($injector, 'horde'); + } + + // Generate signature for connection pooling + $signature = $this->generateSignature($sqlConfig); + + // Return existing adapter or create new + if (!isset($this->adapters[$signature])) { + $this->adapters[$signature] = $this->createAdapter($sqlConfig); + } + + return new StandardHordeDbService($this->adapters[$signature]); + } + + /** + * Create database adapter from configuration + * + * Extracted for testability - can be overridden in tests. + * + * @param array $sqlConfig SQL configuration + * @return Horde_Db_Adapter Database adapter + */ + protected function createAdapter(array $sqlConfig): \Horde_Db_Adapter + { + $phptype = $sqlConfig['phptype'] ?? 'mysqli'; + $adapterClass = $this->getAdapterClass($phptype); + $connectionConfig = $this->buildConnectionConfig($sqlConfig); + return new $adapterClass($connectionConfig); + } + + /** + * Resolve database configuration for service + * + * @param \Horde\Core\Config\State $state Configuration state + * @param string $serviceId Service identifier + * @return array Database configuration + */ + private function resolveConfig($state, string $serviceId): array + { + if ($serviceId === 'horde') { + // System-wide horde connection + return $state->get('sql', []); + } + + // Parse service ID: 'horde:perms' => service = 'perms' + $parts = explode(':', $serviceId, 2); + $service = $parts[1] ?? null; + + if (!$service) { + // Invalid format, fall back to system SQL + return $state->get('sql', []); + } + + // Get service-specific params + $serviceParams = $state->get("{$service}.params", []); + + // Check if explicitly using horde connection + if (($serviceParams['driverconfig'] ?? null) === 'horde') { + return ['driverconfig' => 'horde']; + } + + // Merge with system defaults if service has any DB params + if ($this->hasDbParams($serviceParams)) { + $sqlDefaults = $state->get('sql', []); + return array_merge($sqlDefaults, $serviceParams); + } + + // No service-specific config, use system SQL + return $state->get('sql', []); + } + + /** + * Check if params contain database-specific configuration + * + * @param array $params Configuration parameters + * @return bool True if DB params present + */ + private function hasDbParams(array $params): bool + { + $dbKeys = ['phptype', 'username', 'password', 'database', 'dbname', 'host', 'hostspec', 'port']; + foreach ($dbKeys as $key) { + if (isset($params[$key])) { + return true; + } + } + return false; + } + + /** + * Generate config signature for connection pooling + * + * @param array $config Database configuration + * @return string Configuration signature + */ + private function generateSignature(array $config): string + { + // Remove non-connection fields + unset($config['driverconfig']); + + // Sort for consistent hashing + ksort($config); + + return hash('md5', serialize($config)); + } + + /** + * Get adapter class name from phptype + * + * @param string $phptype Database type ('mysqli', 'mysql', 'pgsql', etc.) + * @return string Fully qualified adapter class name + * @throws InvalidArgumentException If phptype unsupported + */ + private function getAdapterClass(string $phptype): string + { + return match ($phptype) { + 'mysqli' => Horde_Db_Adapter_Mysqli::class, + 'mysql' => Horde_Db_Adapter_Pdo_Mysql::class, + 'pgsql' => Horde_Db_Adapter_Pdo_Pgsql::class, + 'sqlite' => Horde_Db_Adapter_Pdo_Sqlite::class, + default => throw new InvalidArgumentException("Unsupported phptype: $phptype"), + }; + } + + /** + * Build adapter connection configuration + * + * Normalizes config keys across different naming conventions. + * + * @param array $sqlConfig Raw SQL configuration + * @return array Normalized adapter configuration + */ + private function buildConnectionConfig(array $sqlConfig): array + { + return [ + 'username' => $sqlConfig['username'] ?? '', + 'password' => $sqlConfig['password'] ?? '', + 'database' => $sqlConfig['database'] ?? $sqlConfig['dbname'] ?? '', + 'host' => $sqlConfig['hostspec'] ?? $sqlConfig['host'] ?? 'localhost', + 'port' => $sqlConfig['port'] ?? 3306, + 'charset' => $sqlConfig['charset'] ?? 'UTF-8', + ]; + } +} diff --git a/src/Factory/GroupServiceFactory.php b/src/Factory/GroupServiceFactory.php new file mode 100644 index 00000000..5eeef8b4 --- /dev/null +++ b/src/Factory/GroupServiceFactory.php @@ -0,0 +1,50 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\GroupService; +use Horde\Core\Service\SqlGroupService; +use Horde_Injector; + +/** + * Factory for creating GroupService instances + * + * Creates the appropriate GroupService implementation based on the + * configured group driver (SQL, LDAP, etc.). + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class GroupServiceFactory +{ + /** + * Create GroupService instance + * + * @param Horde_Injector $injector Dependency injector + * @return GroupService Group service instance + */ + public function create(Horde_Injector $injector): GroupService + { + // Get the legacy Horde_Group instance (already configured via Horde_Core_Factory_Group) + $groupBackend = $injector->getInstance('Horde_Group'); + + // Wrap in modern service + return new SqlGroupService($groupBackend); + } +} diff --git a/src/Factory/HordeLdapServiceFactory.php b/src/Factory/HordeLdapServiceFactory.php new file mode 100644 index 00000000..bdcd0cf6 --- /dev/null +++ b/src/Factory/HordeLdapServiceFactory.php @@ -0,0 +1,184 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\StandardHordeLdapService; +use Horde\Core\Config\ConfigLoader; +use Horde_Cache; +use Horde_Injector; +use Horde_Ldap; + +/** + * Factory for creating HordeLdapService from configuration + * + * Creates LDAP connection instances based on configuration + * without relying on global state or legacy factories. + * + * Supports connection pooling - services with identical configuration + * share the same connection instance. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class HordeLdapServiceFactory +{ + /** + * Connection pool indexed by config signature + * + * @var array + */ + private array $adapters = []; + + /** + * Create LDAP service from configuration + * + * @param Horde_Injector $injector Dependency injector + * @param string $serviceId Service identifier ('horde', 'horde:groups', etc.) + * @return StandardHordeLdapService LDAP service instance + * @throws \RuntimeException If no LDAP configuration found + */ + public function create(Horde_Injector $injector, string $serviceId = 'horde'): StandardHordeLdapService + { + $loader = $injector->getInstance(ConfigLoader::class); + + // Parse service ID: 'horde' or 'horde:groups' + [$app, $service] = $this->parseServiceId($serviceId); + + $state = $loader->load($app); + + // Resolve config for this service + $ldapConfig = $this->resolveConfig($state, $service); + + // Generate signature for connection pooling + $signature = $this->generateSignature($ldapConfig); + + // Return existing adapter or create new + if (!isset($this->adapters[$signature])) { + $this->adapters[$signature] = $this->createAdapter($ldapConfig, $injector); + } + + return new StandardHordeLdapService($this->adapters[$signature]); + } + + /** + * Parse service identifier into app and service components + * + * Examples: + * - 'horde' → ['horde', null] + * - 'horde:groups' → ['horde', 'groups'] + * - 'imp:storage' → ['imp', 'storage'] + * + * @param string $serviceId Service identifier + * @return array{0: string, 1: string|null} [app, service] + */ + private function parseServiceId(string $serviceId): array + { + if (str_contains($serviceId, ':')) { + return explode(':', $serviceId, 2); + } + return [$serviceId, null]; + } + + /** + * Create LDAP adapter from configuration + * + * Extracted for testability - can be overridden in tests. + * + * @param array $ldapConfig LDAP configuration + * @param Horde_Injector $injector Dependency injector + * @return Horde_Ldap LDAP adapter + */ + protected function createAdapter(array $ldapConfig, Horde_Injector $injector): Horde_Ldap + { + // Add optional cache if available + try { + $cache = $injector->getInstance('Horde_Cache'); + if ($cache instanceof Horde_Cache) { + $ldapConfig['cache'] = $cache; + $ldapConfig['cache_root_dse'] = true; + } + } catch (\Exception $e) { + // Cache not available, continue without it + } + + return new Horde_Ldap($ldapConfig); + } + + /** + * Resolve LDAP configuration for service + * + * Configuration priority: + * 1. Service-specific: $conf['ldap']['service'][$service] (if service given) + * 2. Default app: $conf['ldap'] + * + * @param \Horde\Core\Config\State $state Configuration state + * @param string|null $service Optional service name + * @return array LDAP configuration + * @throws \RuntimeException If no LDAP configuration found + */ + private function resolveConfig($state, ?string $service): array + { + // Service-specific config: ldap.service.groups + if ($service !== null) { + $serviceKey = "ldap.service.$service"; + if ($state->has($serviceKey)) { + return $state->get($serviceKey); + } + } + + // Default app config: ldap + if ($state->has('ldap')) { + $config = $state->get('ldap'); + + // BC: If config is nested under default key, extract it + if (isset($config['hostspec']) || isset($config['basedn'])) { + return $config; + } + + // Otherwise might be service-keyed structure, try default + if (isset($config['default'])) { + return $config['default']; + } + + return $config; + } + + throw new \RuntimeException( + 'No LDAP configuration found for service: ' . + ($service ? "$service" : 'default') + ); + } + + /** + * Generate config signature for connection pooling + * + * @param array $config LDAP configuration + * @return string Configuration signature + */ + private function generateSignature(array $config): string + { + // Remove cache references (not part of connection identity) + unset($config['cache'], $config['cache_root_dse']); + + // Sort for consistent hashing + ksort($config); + + return hash('md5', serialize($config)); + } +} diff --git a/src/Factory/IdentityServiceFactory.php b/src/Factory/IdentityServiceFactory.php new file mode 100644 index 00000000..24fab4ba --- /dev/null +++ b/src/Factory/IdentityServiceFactory.php @@ -0,0 +1,46 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\IdentityService; +use Horde\Core\Service\PrefsService; +use Horde_Injector; + +/** + * Factory for IdentityService + * + * Creates identity management service with PrefsService dependency. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class IdentityServiceFactory +{ + /** + * Create IdentityService instance + * + * @param Horde_Injector $injector Dependency injector + * @return IdentityService Identity service instance + */ + public function create(Horde_Injector $injector): IdentityService + { + $prefsService = $injector->getInstance(PrefsService::class); + return new IdentityService($prefsService); + } +} diff --git a/src/Factory/LdapGroupServiceFactory.php b/src/Factory/LdapGroupServiceFactory.php new file mode 100644 index 00000000..e01b4aeb --- /dev/null +++ b/src/Factory/LdapGroupServiceFactory.php @@ -0,0 +1,65 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\LdapGroupService; +use Horde\Core\Service\HordeLdapService; +use Horde\Core\Config\ConfigLoader; +use Horde_Injector; + +/** + * Factory for creating LDAP-based GroupService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class LdapGroupServiceFactory +{ + /** + * Create LDAP group service from configuration + * + * @param Horde_Injector $injector Dependency injector + * @return LdapGroupService LDAP group service instance + * @throws \RuntimeException If configuration invalid + */ + public function create(Horde_Injector $injector): LdapGroupService + { + $loader = $injector->getInstance(ConfigLoader::class); + $config = $loader->load('horde'); + + // Get LDAP service (may use service-specific connection) + $ldapService = $injector->getInstance(HordeLdapService::class); + + // Get group configuration + $params = $config->get('groups.params', []); + + if (empty($params['basedn'])) { + throw new \RuntimeException('LDAP groups require basedn configuration'); + } + + return new LdapGroupService( + ldapService: $ldapService, + basedn: $params['basedn'], + gidAttr: $params['gid'] ?? 'cn', + memberAttr: $params['memberuid'] ?? 'memberUid', + objectClass: $params['objectclass'] ?? ['posixGroup'], + newGroupObjectClass: $params['newgroup_objectclass'] ?? ['posixGroup'] + ); + } +} diff --git a/src/Factory/LdapPrefsServiceFactory.php b/src/Factory/LdapPrefsServiceFactory.php new file mode 100644 index 00000000..2cb4209e --- /dev/null +++ b/src/Factory/LdapPrefsServiceFactory.php @@ -0,0 +1,61 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\LdapPrefsService; +use Horde\Core\Service\HordeLdapService; +use Horde\Core\Config\ConfigLoader; +use Horde_Injector; + +/** + * Factory for creating LDAP-based PrefsService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class LdapPrefsServiceFactory +{ + /** + * Create LDAP prefs service from configuration + * + * @param Horde_Injector $injector Dependency injector + * @return LdapPrefsService LDAP prefs service instance + * @throws \RuntimeException If configuration invalid + */ + public function create(Horde_Injector $injector): LdapPrefsService + { + $loader = $injector->getInstance(ConfigLoader::class); + $config = $loader->load('horde'); + + // Get LDAP service (may use service-specific connection) + $ldapService = $injector->getInstance(HordeLdapService::class); + + // Get prefs configuration + $params = $config->get('prefs.params', []); + + if (empty($params['basedn'])) { + throw new \RuntimeException('LDAP prefs require basedn configuration'); + } + + return new LdapPrefsService( + ldapService: $ldapService, + basedn: $params['basedn'] + ); + } +} diff --git a/src/Factory/PermissionServiceFactory.php b/src/Factory/PermissionServiceFactory.php new file mode 100644 index 00000000..3cbc366b --- /dev/null +++ b/src/Factory/PermissionServiceFactory.php @@ -0,0 +1,105 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Service\PermissionService; +use Horde\Core\Service\SqlPermissionService; +use Horde\Core\Service\NullPermissionService; +use Horde\Core\Service\GroupService; +use Horde\Core\Config\ConfigLoader; +use Horde_Perms_Sql; +use Horde_Perms_Null; +use Horde_Injector; +use Horde_Cache; +use Horde_Log_Logger; +use RuntimeException; + +/** + * Factory for creating PermissionService with proper backend + * + * Creates permission service from configuration, supporting: + * - SQL backend (with service-specific or shared DB connection) + * - Null backend (permissions disabled) + * + * Handles connection pooling via DbServiceFactory when using SQL backend. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PermissionServiceFactory +{ + /** + * Create PermissionService instance + * + * @param Horde_Injector $injector Dependency injector + * @return PermissionService Permission service with configured backend + * @throws RuntimeException If driver unsupported + */ + public function create(Horde_Injector $injector): PermissionService + { + $loader = $injector->getInstance(ConfigLoader::class); + $state = $loader->load('horde'); + + $driver = strtolower($state->get('perms.driver', 'null')); + $params = $state->get('perms.params', []); + + return match ($driver) { + 'sql' => $this->createSqlBackend($injector, $params), + 'null' => new NullPermissionService(), + default => throw new RuntimeException("Unsupported perms driver: {$driver}"), + }; + } + + /** + * Create SQL permission backend + * + * Uses DbServiceFactory for connection pooling - permissions can use: + * - System-wide horde connection (driverconfig='horde') + * - Service-specific connection (custom DB params) + * + * @param Horde_Injector $injector Dependency injector + * @param array $params Permission configuration parameters + * @return SqlPermissionService SQL permission service + */ + private function createSqlBackend( + Horde_Injector $injector, + array $params + ): SqlPermissionService { + // Get database connection via DbServiceFactory with pooling + $dbFactory = $injector->getInstance(DbServiceFactory::class); + $dbService = $dbFactory->create($injector, 'horde:perms'); + + // Get dependencies + $cache = $injector->getInstance(Horde_Cache::class); + $logger = $injector->getInstance(Horde_Log_Logger::class); + $groupService = $injector->getInstance(GroupService::class); + + // Create legacy Horde_Perms_Sql backend + $permsParams = [ + 'db' => $dbService->getAdapter(), + 'table' => $params['table'] ?? 'horde_perms', + 'cache' => $cache, + 'logger' => $logger, + ]; + + $backend = new Horde_Perms_Sql($permsParams); + + return new SqlPermissionService($backend, $groupService); + } +} diff --git a/src/Factory/PrefsServiceFactory.php b/src/Factory/PrefsServiceFactory.php new file mode 100644 index 00000000..d3543379 --- /dev/null +++ b/src/Factory/PrefsServiceFactory.php @@ -0,0 +1,137 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Factory; + +use Horde\Core\Config\ConfigLoader; +use Horde\Core\Service\PrefsService; +use Horde\Core\Service\SqlPrefsService; +use Horde\Core\Service\NullPrefsService; +use Horde\Core\Service\HordeDbService; +use Horde_Injector; +use RuntimeException; + +/** + * Factory for PrefsService from configuration + * + * Creates prefs storage backend based on conf.php settings. + * Supports SQL initially, designed for expansion. + * + * Currently implemented: + * - 'sql': SQL storage (Horde_Prefs_Storage_Sql) + * - 'null', 'session': Null storage (in-memory only) + * + * TODO: Implement additional backends as needed: + * + * LDAP Backend: + * - Driver: 'ldap' + * - Requires: LdapService (similar to DbServiceFactory pattern) + * - Config: $conf['prefs']['params'] with: + * - hostspec: LDAP server hostname + * - port: LDAP port (default 389) + * - basedn: Base DN for prefs (e.g., 'ou=prefs,dc=example,dc=com') + * - uid: Attribute for username (default 'uid') + * - binddn: Bind DN for authentication + * - bindpw: Bind password + * - Notes: LDAP has limited attribute storage, less common for prefs + * - Implementation: Create LdapServiceFactory, then Horde_Prefs_Storage_Ldap wrapper + * + * NoSQL Backend (MongoDB): + * - Driver: 'nosql' or 'mongo' + * - Requires: NoSQL service factory + * - Config: $conf['nosql'] with connection params + * - Notes: Check $conf['prefs']['driver'] == 'nosql', detect backend type (mongo/redis) + * - Implementation: Create NoSqlServiceFactory, wrap Horde_Prefs_Storage_Mongo + * + * File Backend: + * - Driver: 'file' + * - Requires: File path configuration only + * - Config: $conf['prefs']['params']['directory'] - Path to store prefs files + * - Notes: Simple, no service dependencies, but slow at scale + * - Implementation: Wrap Horde_Prefs_Storage_File with directory param + * + * Kolab IMAP Backend: + * - Driver: 'kolab_imap' + * - Requires: Kolab session/storage service + * - Config: Uses authenticated user's IMAP connection + * - Notes: Unavailable for admin users, stores prefs in IMAP folders + * - Implementation: Create KolabServiceFactory, wrap Horde_Prefs_Storage_KolabImap + * + * App:Service Pattern: + * - Factory supports 'app:service' notation for DB connections + * - Example: $conf['prefs']['params']['driverconfig'] = 'horde:prefs' + * - Gets DB connection specific to that service: DbServiceFactory->create('horde', 'prefs') + * - For now, we use default 'horde' SQL connection + * - Future: Extend DbServiceFactory to accept optional service parameter + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PrefsServiceFactory +{ + /** + * Create PrefsService instance + * + * @param Horde_Injector $injector Dependency injector + * @return PrefsService Prefs service instance + * @throws RuntimeException If driver unsupported + */ + public function create(Horde_Injector $injector): PrefsService + { + $loader = $injector->getInstance(ConfigLoader::class); + $state = $loader->load('horde'); + + $driver = $state->get('prefs.driver', 'sql'); + $params = $state->get('prefs.params', []); + + return match (strtolower($driver)) { + 'sql' => $this->createSqlBackend($injector, $params), + 'null', 'session' => $this->createNullBackend(), + default => throw new RuntimeException("Unsupported prefs driver: $driver (TODO: implement)"), + }; + } + + /** + * Create SQL prefs backend + * + * @param Horde_Injector $injector Dependency injector + * @param array $params Prefs configuration parameters + * @return SqlPrefsService SQL prefs service + */ + private function createSqlBackend(Horde_Injector $injector, array $params): SqlPrefsService + { + // Get DB service (supports 'horde:prefs' pattern in future) + $dbService = $injector->getInstance(HordeDbService::class); + + $table = $params['table'] ?? 'horde_prefs'; + + return new SqlPrefsService($dbService->getAdapter(), $table); + } + + /** + * Create null prefs backend + * + * In-memory only, for testing or session-based prefs. + * + * @return NullPrefsService Null prefs service + */ + private function createNullBackend(): NullPrefsService + { + return new NullPrefsService(); + } +} diff --git a/src/Factory/RegistryConfigLoaderFactory.php b/src/Factory/RegistryConfigLoaderFactory.php new file mode 100644 index 00000000..6faee671 --- /dev/null +++ b/src/Factory/RegistryConfigLoaderFactory.php @@ -0,0 +1,40 @@ +registryState = $registryLoader->load(); + } + + /** + * List all applications with metadata + * + * @param bool $includeNonApps Include non-app entries (headings, etc.) + * @return array Array of application data + */ + public function listApplications(bool $includeNonApps = false): array + { + $apps = []; + foreach ($this->registryState->listApplications() as $appName) { + $appData = $this->registryState->getApplication($appName); + if ($appData) { + $appInfo = $this->buildApplicationInfo($appName, $appData); + + // Filter out non-apps unless requested + if (!$includeNonApps && !$appInfo['isApp']) { + continue; + } + + $apps[] = $appInfo; + } + } + return $apps; + } + + /** + * Get single application by name + * + * @param string $name App name + * @return array|null App data or null if not found + */ + public function getApplication(string $name): ?array + { + $appData = $this->registryState->getApplication($name); + if (!$appData) { + return null; + } + return $this->buildApplicationInfo($name, $appData); + } + + /** + * Build application info array from registry data + * + * Phase 1: Basic composer + registry level introspection + * + * Status types from registry.php documentation: + * - active: Activate application (default) + * - admin: Activate application, but only for admins + * - heading: Header label for application groups (NOT an app) + * - hidden: Enable application, but hide + * - inactive: Disable application + * - link: Add a link to an external url (NOT a Horde app) + * - noadmin: Disable application for authenticated admins + * - notoolbar: Activate application, but hide from menus + * - topbar: Show in topbar only (NOT an app, widget referencing app) + * + * @param string $name App name + * @param array $data Registry data + * @return array Application metadata + */ + private function buildApplicationInfo(string $name, array $data): array + { + $status = $data['status'] ?? 'active'; // Default is 'active' per docs + + // Determine if this is an actual app or just a UI element + // Non-apps: heading (grouping), topbar (widget), link (external URL) + $isApp = !in_array($status, ['heading', 'topbar', 'link']); + + return [ + 'name' => $name, + 'displayName' => $data['name'] ?? $name, // Translated name from registry + 'version' => $this->getVersion($name, $data), + 'status' => $status, + 'isApp' => $isApp, + 'active' => in_array($status, ['active', 'admin']), + 'ready' => in_array($status, ['active', 'admin']), // Phase 1: stub + 'webroot' => $data['webroot'] ?? '', + 'fileroot' => $data['fileroot'] ?? '', + ]; + } + + /** + * Get application version from .horde.yml + * + * Phase 1: Read from .horde.yml only + * Future phases: Support composer.json fallback + * + * @param string $name App name + * @param array $data Registry data + * @return string Version string + */ + private function getVersion(string $name, array $data): string + { + // Check registry data first (rarely present) + if (isset($data['version'])) { + return $data['version']; + } + + // Try to read from .horde.yml (primary source) + if (isset($data['fileroot']) && function_exists('yaml_parse_file')) { + $hordeYml = $data['fileroot'] . '/.horde.yml'; + if (file_exists($hordeYml)) { + try { + $yaml = yaml_parse_file($hordeYml); + if (isset($yaml['version']['release'])) { + return $yaml['version']['release']; + } + } catch (\Exception $e) { + // yaml_parse_file failed, fall through to unknown + } + } + } + + return 'unknown'; + } +} diff --git a/src/Service/Exception/GroupExistsException.php b/src/Service/Exception/GroupExistsException.php new file mode 100644 index 00000000..410c37be --- /dev/null +++ b/src/Service/Exception/GroupExistsException.php @@ -0,0 +1,29 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service\Exception; + +/** + * Exception thrown when attempting to create a group that already exists + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class GroupExistsException extends \RuntimeException +{ +} diff --git a/src/Service/Exception/GroupNotFoundException.php b/src/Service/Exception/GroupNotFoundException.php new file mode 100644 index 00000000..12510e26 --- /dev/null +++ b/src/Service/Exception/GroupNotFoundException.php @@ -0,0 +1,29 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service\Exception; + +/** + * Exception thrown when a requested group cannot be found + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class GroupNotFoundException extends \RuntimeException +{ +} diff --git a/src/Service/Exception/PermissionNotFoundException.php b/src/Service/Exception/PermissionNotFoundException.php new file mode 100644 index 00000000..137d7871 --- /dev/null +++ b/src/Service/Exception/PermissionNotFoundException.php @@ -0,0 +1,31 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service\Exception; + +use RuntimeException; + +/** + * Exception thrown when a permission is not found + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PermissionNotFoundException extends RuntimeException +{ +} diff --git a/src/Service/FileGroupService.php b/src/Service/FileGroupService.php new file mode 100644 index 00000000..e6484d15 --- /dev/null +++ b/src/Service/FileGroupService.php @@ -0,0 +1,255 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\GroupNotFoundException; +use Horde\Core\Service\Exception\GroupExistsException; +use RuntimeException; + +/** + * File-based group service for Unix /etc/group integration + * + * Parses Unix group file format (group_name:x:GID:user,list). + * Read-only backend - groups managed by system tools. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class FileGroupService implements GroupService +{ + /** + * Parsed groups from file + * + * @var array + */ + private array $groups = []; + + /** + * Constructor + * + * @param string $file Path to group file (default: /etc/group) + * @param bool $useGid Use numeric GID as ID instead of group name + * @throws RuntimeException If file cannot be read + */ + public function __construct( + private string $file = '/etc/group', + private bool $useGid = false + ) { + $this->loadGroups(); + } + + /** + * Load and parse the group file + * + * @throws RuntimeException + */ + private function loadGroups(): void + { + if (!file_exists($this->file)) { + throw new RuntimeException("Group file '{$this->file}' not found"); + } + + if (!is_readable($this->file)) { + throw new RuntimeException("Group file '{$this->file}' is not readable"); + } + + $lines = file($this->file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + throw new RuntimeException("Failed to read group file '{$this->file}'"); + } + + foreach ($lines as $line) { + // Skip comments + if (str_starts_with(trim($line), '#')) { + continue; + } + + // Format: group_name:encrypted_passwd:GID:user_list + $parts = explode(':', $line, 4); + if (count($parts) < 3) { + continue; // Skip malformed lines + } + + [$name, $pass, $gid] = $parts; + $users = isset($parts[3]) && $parts[3] !== '' + ? explode(',', trim($parts[3])) + : []; + + // Use GID or group name as ID based on config + $id = $this->useGid ? $gid : $name; + + $this->groups[$id] = new GroupInfo( + id: $id, + name: $name, + members: array_values(array_filter($users)) // Remove empty strings + ); + } + } + + /** + * List all groups with pagination + * + * @param int $page Page number (1-indexed) + * @param int $perPage Number of groups per page + * @return GroupListResult Paginated result + */ + public function listAll(int $page = 1, int $perPage = 50): GroupListResult + { + $total = count($this->groups); + $offset = ($page - 1) * $perPage; + + $slice = array_slice($this->groups, $offset, $perPage, true); + + return new GroupListResult( + groups: array_values($slice), + total: $total, + page: $page, + perPage: $perPage, + hasNext: ($offset + $perPage) < $total, + hasPrev: $page > 1 + ); + } + + /** + * Get a specific group by identifier + * + * @param string $identifier Group ID or name + * @return GroupInfo Group information + * @throws GroupNotFoundException + */ + public function get(string $identifier): GroupInfo + { + // Try by ID first + if (isset($this->groups[$identifier])) { + return $this->groups[$identifier]; + } + + // Try by name + foreach ($this->groups as $group) { + if ($group->name === $identifier) { + return $group; + } + } + + throw new GroupNotFoundException("Group '$identifier' not found"); + } + + /** + * Check if a group exists + * + * @param string $identifier Group ID or name + * @return bool + */ + public function exists(string $identifier): bool + { + // Check by ID + if (isset($this->groups[$identifier])) { + return true; + } + + // Check by name + foreach ($this->groups as $group) { + if ($group->name === $identifier) { + return true; + } + } + + return false; + } + + /** + * Get members of a group + * + * @param string $identifier Group ID or name + * @return array List of usernames + * @throws GroupNotFoundException + */ + public function getMembers(string $identifier): array + { + return $this->get($identifier)->members; + } + + /** + * Check if backend is read-only + * + * @return bool Always true (file backend is read-only) + */ + public function isReadOnly(): bool + { + return true; + } + + // ========== READ-ONLY BACKEND - WRITE METHODS THROW =========== + + /** + * @throws RuntimeException + */ + public function create(string $name): GroupInfo + { + throw new RuntimeException('File backend is read-only. Use system tools (groupadd) to create groups.'); + } + + /** + * @throws RuntimeException + */ + public function delete(string $identifier): void + { + throw new RuntimeException('File backend is read-only. Use system tools (groupdel) to delete groups.'); + } + + /** + * @throws RuntimeException + */ + public function addMember(string $identifier, string $username): void + { + throw new RuntimeException('File backend is read-only. Use system tools (gpasswd -a) to add members.'); + } + + /** + * @throws RuntimeException + */ + public function addMembers(string $identifier, array $usernames): void + { + throw new RuntimeException('File backend is read-only. Use system tools to add members.'); + } + + /** + * @throws RuntimeException + */ + public function removeMember(string $identifier, string $username): void + { + throw new RuntimeException('File backend is read-only. Use system tools (gpasswd -d) to remove members.'); + } + + /** + * @throws RuntimeException + */ + public function removeMembers(string $identifier, array $usernames): void + { + throw new RuntimeException('File backend is read-only. Use system tools to remove members.'); + } + + /** + * @throws RuntimeException + */ + public function setMembers(string $identifier, array $usernames): void + { + throw new RuntimeException('File backend is read-only. Use system tools to set members.'); + } +} diff --git a/src/Service/FilePrefsService.php b/src/Service/FilePrefsService.php new file mode 100644 index 00000000..474b11ea --- /dev/null +++ b/src/Service/FilePrefsService.php @@ -0,0 +1,202 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use RuntimeException; + +/** + * File-based preferences storage service + * + * Stores user preferences as serialized PHP arrays in individual files. + * Directory structure: {directory}/{scope}/{uid}.prefs + * + * Format: PHP serialized array of key => value pairs per scope. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class FilePrefsService implements PrefsService +{ + /** + * Constructor + * + * @param string $directory Base directory for prefs files + * @throws RuntimeException If directory is not writable + */ + public function __construct( + private string $directory + ) { + if (!is_dir($this->directory)) { + if (!mkdir($this->directory, 0o700, true)) { + throw new RuntimeException("Failed to create prefs directory: {$this->directory}"); + } + } + + if (!is_writable($this->directory)) { + throw new RuntimeException("Prefs directory is not writable: {$this->directory}"); + } + } + + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return mixed|null Value or null if not found + */ + public function getValue(string $uid, string $scope, string $key) + { + $data = $this->loadScopeData($uid, $scope); + return $data[$key] ?? null; + } + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @param mixed $value Preference value + */ + public function setValue(string $uid, string $scope, string $key, $value): void + { + $data = $this->loadScopeData($uid, $scope); + $data[$key] = $value; + $this->saveScopeData($uid, $scope, $data); + } + + /** + * Delete preference + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + */ + public function deleteValue(string $uid, string $scope, string $key): void + { + $data = $this->loadScopeData($uid, $scope); + + if (isset($data[$key])) { + unset($data[$key]); + $this->saveScopeData($uid, $scope, $data); + } + } + + /** + * Get all preferences for user in scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Associative array of key => value + */ + public function getAllInScope(string $uid, string $scope): array + { + return $this->loadScopeData($uid, $scope); + } + + /** + * Check if preference exists + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool + */ + public function exists(string $uid, string $scope, string $key): bool + { + $data = $this->loadScopeData($uid, $scope); + return array_key_exists($key, $data); + } + + /** + * Get file path for user's scope preferences + * + * @param string $uid User ID + * @param string $scope App name + * @return string Full file path + */ + private function getFilePath(string $uid, string $scope): string + { + // Sanitize uid to prevent directory traversal + $safeUid = basename($uid); + + // Create scope directory if needed + $scopeDir = $this->directory . '/' . $scope; + if (!is_dir($scopeDir)) { + mkdir($scopeDir, 0o700, true); + } + + return $scopeDir . '/' . $safeUid . '.prefs'; + } + + /** + * Load preference data for user and scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Preference data + */ + private function loadScopeData(string $uid, string $scope): array + { + $file = $this->getFilePath($uid, $scope); + + if (!file_exists($file)) { + return []; + } + + $contents = file_get_contents($file); + if ($contents === false) { + throw new RuntimeException("Failed to read prefs file: $file"); + } + + // Suppress warnings for corrupted files + $data = @unserialize($contents); + if ($data === false && $contents !== serialize(false)) { + // File corrupted, return empty array + return []; + } + + return is_array($data) ? $data : []; + } + + /** + * Save preference data for user and scope + * + * @param string $uid User ID + * @param string $scope App name + * @param array $data Preference data + */ + private function saveScopeData(string $uid, string $scope, array $data): void + { + $file = $this->getFilePath($uid, $scope); + + $serialized = serialize($data); + + // Use LOCK_EX to prevent concurrent write corruption + $result = file_put_contents($file, $serialized, LOCK_EX); + + if ($result === false) { + throw new RuntimeException("Failed to write prefs file: $file"); + } + + // Set restrictive permissions (owner read/write only) + chmod($file, 0o600); + } +} diff --git a/src/Service/GroupInfo.php b/src/Service/GroupInfo.php new file mode 100644 index 00000000..aadbbc9a --- /dev/null +++ b/src/Service/GroupInfo.php @@ -0,0 +1,79 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * Group information domain object + * + * Immutable value object representing a group with its members. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class GroupInfo +{ + /** + * Constructor + * + * @param string $id Group identifier (group_uid for SQL, DN for LDAP) + * @param string $name Group name (group_name for SQL, cn for LDAP) + * @param array $members List of usernames who are members + * @param array $extra Backend-specific extra fields (reserved for future use) + */ + public function __construct( + public readonly string $id, + public readonly string $name, + public readonly array $members, + public readonly array $extra = [] + ) { + } + + /** + * Create from backend data array + * + * Factory method to create GroupInfo from Horde_Group_Base::getData() result. + * + * @param array $data Backend data array with keys: group_uid, group_name, etc. + * @param array $members List of usernames + * @return self + */ + public static function fromBackendData(array $data, array $members): self + { + return new self( + id: (string) $data['group_uid'], + name: $data['group_name'], + members: $members, + extra: [] + ); + } + + /** + * Convert to array representation + * + * @return array Associative array with id, name, members + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'members' => $this->members, + ]; + } +} diff --git a/src/Service/GroupListResult.php b/src/Service/GroupListResult.php new file mode 100644 index 00000000..033cc4c5 --- /dev/null +++ b/src/Service/GroupListResult.php @@ -0,0 +1,50 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * Paginated group list result + * + * Contains a page of groups plus pagination metadata. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class GroupListResult +{ + /** + * Constructor + * + * @param GroupInfo[] $groups Array of GroupInfo objects for this page + * @param int $total Total number of groups across all pages + * @param int $page Current page number (1-indexed) + * @param int $perPage Number of groups per page + * @param bool $hasNext Whether there are more pages after this one + * @param bool $hasPrev Whether there are pages before this one + */ + public function __construct( + public readonly array $groups, + public readonly int $total, + public readonly int $page, + public readonly int $perPage, + public readonly bool $hasNext, + public readonly bool $hasPrev + ) { + } +} diff --git a/src/Service/GroupService.php b/src/Service/GroupService.php new file mode 100644 index 00000000..9bf8d684 --- /dev/null +++ b/src/Service/GroupService.php @@ -0,0 +1,156 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\GroupNotFoundException; +use Horde\Core\Service\Exception\GroupExistsException; + +/** + * Group service interface + * + * Modern service interface for group management, wrapping legacy Horde_Group backends. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface GroupService +{ + /** + * List all groups with pagination + * + * @param int $page Page number (1-indexed) + * @param int $perPage Number of groups per page + * @return GroupListResult Paginated result with groups and metadata + */ + public function listAll(int $page = 1, int $perPage = 50): GroupListResult; + + /** + * Get a specific group by identifier + * + * Identifier can be either the group ID (numeric for SQL, DN for LDAP) + * or the group name. The implementation tries ID first, then name lookup. + * + * @param string $identifier Group ID or name + * @return GroupInfo Group information with members + * @throws GroupNotFoundException If group not found + */ + public function get(string $identifier): GroupInfo; + + /** + * Check if a group exists + * + * @param string $identifier Group ID or name + * @return bool True if group exists + */ + public function exists(string $identifier): bool; + + /** + * Create a new group + * + * @param string $name Group name (must be unique) + * @return GroupInfo The created group information + * @throws GroupExistsException If a group with this name already exists + */ + public function create(string $name): GroupInfo; + + /** + * Delete a group + * + * @param string $identifier Group ID or name + * @return void + * @throws GroupNotFoundException If group not found + */ + public function delete(string $identifier): void; + + /** + * Get members of a group + * + * @param string $identifier Group ID or name + * @return array List of usernames + * @throws GroupNotFoundException If group not found + */ + public function getMembers(string $identifier): array; + + /** + * Add a single member to a group (idempotent) + * + * If the user is already a member, this is a no-op. + * + * @param string $identifier Group ID or name + * @param string $username Username to add + * @return void + * @throws GroupNotFoundException If group not found + */ + public function addMember(string $identifier, string $username): void; + + /** + * Add multiple members to a group (idempotent, batch operation) + * + * Only adds users who are not already members. + * + * @param string $identifier Group ID or name + * @param array $usernames Array of usernames to add + * @return void + * @throws GroupNotFoundException If group not found + */ + public function addMembers(string $identifier, array $usernames): void; + + /** + * Remove a single member from a group (idempotent) + * + * If the user is not a member, this is a no-op. + * + * @param string $identifier Group ID or name + * @param string $username Username to remove + * @return void + * @throws GroupNotFoundException If group not found + */ + public function removeMember(string $identifier, string $username): void; + + /** + * Remove multiple members from a group (idempotent, batch operation) + * + * Only removes users who are currently members. + * + * @param string $identifier Group ID or name + * @param array $usernames Array of usernames to remove + * @return void + * @throws GroupNotFoundException If group not found + */ + public function removeMembers(string $identifier, array $usernames): void; + + /** + * Replace all members of a group + * + * Removes all current members and adds the specified members. + * + * @param string $identifier Group ID or name + * @param array $usernames Array of usernames (new member list) + * @return void + * @throws GroupNotFoundException If group not found + */ + public function setMembers(string $identifier, array $usernames): void; + + /** + * Check if the backend is read-only + * + * @return bool True if backend does not support write operations + */ + public function isReadOnly(): bool; +} diff --git a/src/Service/HordeDbService.php b/src/Service/HordeDbService.php new file mode 100644 index 00000000..814ca8b2 --- /dev/null +++ b/src/Service/HordeDbService.php @@ -0,0 +1,40 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Db_Adapter; + +/** + * Database service interface for Horde + * + * Provides access to Horde's database adapter. Future versions + * may support multi-app database connections. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface HordeDbService +{ + /** + * Get database adapter + * + * @return Horde_Db_Adapter Database adapter instance + */ + public function getAdapter(): Horde_Db_Adapter; +} diff --git a/src/Service/HordeLdapService.php b/src/Service/HordeLdapService.php new file mode 100644 index 00000000..cb380640 --- /dev/null +++ b/src/Service/HordeLdapService.php @@ -0,0 +1,40 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Ldap; + +/** + * LDAP service interface for Horde + * + * Provides access to Horde's LDAP connections. Implementations + * support multi-app and service-specific LDAP connections. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface HordeLdapService +{ + /** + * Get LDAP connection + * + * @return Horde_Ldap LDAP connection instance + */ + public function getAdapter(): Horde_Ldap; +} diff --git a/src/Service/IdentityNotFoundException.php b/src/Service/IdentityNotFoundException.php new file mode 100644 index 00000000..1090e5ff --- /dev/null +++ b/src/Service/IdentityNotFoundException.php @@ -0,0 +1,31 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use RuntimeException; + +/** + * Exception thrown when an identity is not found + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class IdentityNotFoundException extends RuntimeException +{ +} diff --git a/src/Service/IdentityService.php b/src/Service/IdentityService.php new file mode 100644 index 00000000..2fb58203 --- /dev/null +++ b/src/Service/IdentityService.php @@ -0,0 +1,173 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * Modern wrapper for identity management + * + * Wraps preference-based identity storage with clean API for REST services. + * Identities are tied to a user UID but don't require auth backend user. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class IdentityService +{ + /** + * Constructor + * + * @param PrefsService $prefsService Preferences service + */ + public function __construct( + private PrefsService $prefsService + ) { + } + + /** + * Get all identities for user + * + * @param string $uid User ID + * @param string $scope App scope (default: 'horde') + * @return array Array of identities (each with id, fullname, from_addr, etc.) + */ + public function getAll(string $uid, string $scope = 'horde'): array + { + $identities = $this->prefsService->getValue($uid, $scope, 'identities'); + if (!$identities) { + return []; + } + $unserialized = @unserialize($identities); + return is_array($unserialized) ? $unserialized : []; + } + + /** + * Get single identity by index + * + * @param string $uid User ID + * @param int $index Identity index (0-based) + * @param string $scope App scope + * @return array|null Identity or null if not found + */ + public function get(string $uid, int $index, string $scope = 'horde'): ?array + { + $identities = $this->getAll($uid, $scope); + return $identities[$index] ?? null; + } + + /** + * Add new identity + * + * @param string $uid User ID + * @param array $identity Identity data (id, fullname, from_addr, etc.) + * @param string $scope App scope + * @return int Index of created identity + */ + public function add(string $uid, array $identity, string $scope = 'horde'): int + { + $identities = $this->getAll($uid, $scope); + $identities[] = $identity; + $this->save($uid, $identities, $scope); + return count($identities) - 1; + } + + /** + * Update existing identity + * + * @param string $uid User ID + * @param int $index Identity index + * @param array $identity New identity data + * @param string $scope App scope + * @throws IdentityNotFoundException + */ + public function update(string $uid, int $index, array $identity, string $scope = 'horde'): void + { + $identities = $this->getAll($uid, $scope); + if (!isset($identities[$index])) { + throw new IdentityNotFoundException("Identity $index not found for user $uid"); + } + $identities[$index] = $identity; + $this->save($uid, $identities, $scope); + } + + /** + * Delete identity + * + * @param string $uid User ID + * @param int $index Identity index + * @param string $scope App scope + * @throws IdentityNotFoundException + */ + public function delete(string $uid, int $index, string $scope = 'horde'): void + { + $identities = $this->getAll($uid, $scope); + if (!isset($identities[$index])) { + throw new IdentityNotFoundException("Identity $index not found for user $uid"); + } + array_splice($identities, $index, 1); + $this->save($uid, $identities, $scope); + + // Update default if needed + $default = $this->getDefault($uid, $scope); + if ($default >= count($identities)) { + $this->setDefault($uid, 0, $scope); + } + } + + /** + * Get default identity index + * + * @param string $uid User ID + * @param string $scope App scope + * @return int Default identity index (0-based) + */ + public function getDefault(string $uid, string $scope = 'horde'): int + { + $default = $this->prefsService->getValue($uid, $scope, 'default_identity'); + return $default ? (int)$default : 0; + } + + /** + * Set default identity + * + * @param string $uid User ID + * @param int $index Identity index + * @param string $scope App scope + * @throws IdentityNotFoundException + */ + public function setDefault(string $uid, int $index, string $scope = 'horde'): void + { + $identities = $this->getAll($uid, $scope); + if (!isset($identities[$index])) { + throw new IdentityNotFoundException("Identity $index not found for user $uid"); + } + $this->prefsService->setValue($uid, $scope, 'default_identity', (string)$index); + } + + /** + * Save identities to backend + * + * @param string $uid User ID + * @param array $identities Array of identity data + * @param string $scope App scope + */ + private function save(string $uid, array $identities, string $scope): void + { + $this->prefsService->setValue($uid, $scope, 'identities', serialize($identities)); + } +} diff --git a/src/Service/LdapGroupService.php b/src/Service/LdapGroupService.php new file mode 100644 index 00000000..106b400c --- /dev/null +++ b/src/Service/LdapGroupService.php @@ -0,0 +1,425 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\HordeLdapService; +use Horde_Ldap; +use Horde_Ldap_Filter; +use Horde_Ldap_Exception; + +/** + * LDAP-based group service implementation + * + * Manages groups stored in LDAP directory with support for multiple + * objectClass schemas (posixGroup, groupOfNames, groupOfUniqueNames). + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class LdapGroupService implements GroupService +{ + /** + * Constructor + * + * @param HordeLdapService $ldapService LDAP service for connections + * @param string $basedn Base DN for group searches + * @param string $gidAttr Attribute for group ID (default: 'cn') + * @param string $memberAttr Attribute for member list (default: 'memberUid') + * @param array $objectClass Object classes for searching (default: ['posixGroup']) + * @param array $newGroupObjectClass Object classes for new groups (default: ['posixGroup']) + */ + public function __construct( + private HordeLdapService $ldapService, + private string $basedn, + private string $gidAttr = 'cn', + private string $memberAttr = 'memberUid', + private array $objectClass = ['posixGroup'], + private array $newGroupObjectClass = ['posixGroup'] + ) { + } + + /** + * List all groups + * + * @param int $page Page number (1-based) + * @param int $perPage Number of groups per page + * @return GroupListResult List of all groups + * @throws \RuntimeException If LDAP search fails + */ + public function listAll(int $page = 1, int $perPage = 50): GroupListResult + { + try { + $ldap = $this->ldapService->getAdapter(); + $filter = $this->buildFilter(); + + $search = $ldap->search($this->basedn, $filter, [ + 'attributes' => [$this->gidAttr, $this->memberAttr, 'mail', 'description'], + ]); + + $allGroups = []; + foreach ($search as $entry) { + $gid = $entry->getValue($this->gidAttr, 'single'); + $members = $entry->getValue($this->memberAttr); + $mail = $entry->getValue('mail', 'single'); + + $extra = []; + if ($mail) { + $extra['email'] = $mail; + } + + $allGroups[] = new GroupInfo( + id: $gid, + name: $gid, + members: is_array($members) ? $members : [], + extra: $extra + ); + } + + // Apply pagination + $total = count($allGroups); + $offset = ($page - 1) * $perPage; + $groups = array_slice($allGroups, $offset, $perPage); + + $hasNext = ($offset + $perPage) < $total; + $hasPrev = $page > 1; + + return new GroupListResult($groups, $total, $page, $perPage, $hasNext, $hasPrev); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException('Failed to list LDAP groups: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Get group by ID + * + * @param string $id Group ID + * @return GroupInfo Group information + * @throws \RuntimeException If group not found or LDAP error + */ + public function get(string $id): GroupInfo + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($id); + + $entry = $ldap->getEntry($dn, [ + 'attributes' => [$this->gidAttr, $this->memberAttr, 'mail', 'description'], + ]); + + $members = $entry->getValue($this->memberAttr); + $mail = $entry->getValue('mail', 'single'); + + $extra = []; + if ($mail) { + $extra['email'] = $mail; + } + + return new GroupInfo( + id: $id, + name: $id, + members: is_array($members) ? $members : [], + extra: $extra + ); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to get LDAP group '$id': " . $e->getMessage(), 0, $e); + } + } + + /** + * Create new group + * + * @param string $name Group name (must be unique) + * @return GroupInfo Created group information + * @throws \RuntimeException If group creation fails + */ + public function create(string $name): GroupInfo + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($name); + + $attributes = [ + 'objectClass' => $this->newGroupObjectClass, + $this->gidAttr => $name, + ]; + + // posixGroup requires gidNumber + if (in_array('posixGroup', $this->newGroupObjectClass)) { + $attributes['gidNumber'] = $this->getNextGidNumber(); + } + + $entry = \Horde_Ldap_Entry::createFresh($dn, $attributes); + $ldap->add($entry); + + return new GroupInfo( + id: $name, + name: $name, + members: [], + extra: [] + ); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to create LDAP group '$name': " . $e->getMessage(), 0, $e); + } + } + + /** + * Delete group + * + * @param string $id Group ID + * @throws \RuntimeException If group deletion fails + */ + public function delete(string $id): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($id); + + $ldap->delete($dn); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to delete LDAP group '$id': " . $e->getMessage(), 0, $e); + } + } + + /** + * Check if group exists + * + * @param string $id Group ID + * @return bool True if group exists + */ + public function exists(string $id): bool + { + try { + $this->get($id); + return true; + } catch (\RuntimeException $e) { + return false; + } + } + + /** + * Get group members + * + * @param string $groupId Group ID + * @return array List of user IDs + * @throws \RuntimeException If operation fails + */ + public function getMembers(string $groupId): array + { + $group = $this->get($groupId); + return $group->members; + } + + /** + * Add member to group + * + * @param string $groupId Group ID + * @param string $userId User ID + * @throws \RuntimeException If operation fails + */ + public function addMember(string $groupId, string $userId): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($groupId); + + $entry = $ldap->getEntry($dn); + $members = $entry->getValue($this->memberAttr); + $members = is_array($members) ? $members : []; + + if (!in_array($userId, $members)) { + $members[] = $userId; + $entry->replace([$this->memberAttr => $members]); + $entry->update(); + } + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to add member '$userId' to LDAP group '$groupId': " . $e->getMessage(), 0, $e); + } + } + + /** + * Add multiple members to group + * + * @param string $groupId Group ID + * @param array $userIds Array of user IDs + * @throws \RuntimeException If operation fails + */ + public function addMembers(string $groupId, array $userIds): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($groupId); + + $entry = $ldap->getEntry($dn); + $members = $entry->getValue($this->memberAttr); + $members = is_array($members) ? $members : []; + + $newMembers = array_unique(array_merge($members, $userIds)); + + if (count($newMembers) !== count($members)) { + $entry->replace([$this->memberAttr => $newMembers]); + $entry->update(); + } + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to add members to LDAP group '$groupId': " . $e->getMessage(), 0, $e); + } + } + + /** + * Remove member from group + * + * @param string $groupId Group ID + * @param string $userId User ID + * @throws \RuntimeException If operation fails + */ + public function removeMember(string $groupId, string $userId): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($groupId); + + $entry = $ldap->getEntry($dn); + $members = $entry->getValue($this->memberAttr); + $members = is_array($members) ? $members : []; + + $members = array_values(array_filter($members, fn ($m) => $m !== $userId)); + + $entry->replace([$this->memberAttr => $members]); + $entry->update(); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to remove member '$userId' from LDAP group '$groupId': " . $e->getMessage(), 0, $e); + } + } + + /** + * Remove multiple members from group + * + * @param string $groupId Group ID + * @param array $userIds Array of user IDs + * @throws \RuntimeException If operation fails + */ + public function removeMembers(string $groupId, array $userIds): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($groupId); + + $entry = $ldap->getEntry($dn); + $members = $entry->getValue($this->memberAttr); + $members = is_array($members) ? $members : []; + + $members = array_values(array_diff($members, $userIds)); + + $entry->replace([$this->memberAttr => $members]); + $entry->update(); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to remove members from LDAP group '$groupId': " . $e->getMessage(), 0, $e); + } + } + + /** + * Replace all members of a group + * + * @param string $groupId Group ID + * @param array $userIds Array of user IDs (new member list) + * @throws \RuntimeException If operation fails + */ + public function setMembers(string $groupId, array $userIds): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $dn = $this->buildDN($groupId); + + $entry = $ldap->getEntry($dn); + $entry->replace([$this->memberAttr => $userIds]); + $entry->update(); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to set members for LDAP group '$groupId': " . $e->getMessage(), 0, $e); + } + } + + /** + * Check if backend is read-only + * + * @return bool False - LDAP backend supports write operations + */ + public function isReadOnly(): bool + { + return false; + } + + /** + * Build LDAP filter for group searches + * + * @return Horde_Ldap_Filter LDAP filter + */ + private function buildFilter(): Horde_Ldap_Filter + { + if (count($this->objectClass) === 1) { + return Horde_Ldap_Filter::create('objectClass', 'equals', $this->objectClass[0]); + } + + $filters = []; + foreach ($this->objectClass as $oc) { + $filters[] = Horde_Ldap_Filter::create('objectClass', 'equals', $oc); + } + + return Horde_Ldap_Filter::combine('or', $filters); + } + + /** + * Build DN for group + * + * @param string $groupId Group ID + * @return string Full DN + */ + private function buildDN(string $groupId): string + { + return "{$this->gidAttr}={$groupId},{$this->basedn}"; + } + + /** + * Get next available GID number for posixGroup + * + * @return int Next GID number + */ + private function getNextGidNumber(): int + { + try { + $ldap = $this->ldapService->getAdapter(); + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'posixGroup'); + + $search = $ldap->search($this->basedn, $filter, [ + 'attributes' => ['gidNumber'], + ]); + + $maxGid = 10000; // Start from 10000 for safety + foreach ($search as $entry) { + $gid = (int) $entry->getValue('gidNumber', 'single'); + if ($gid > $maxGid) { + $maxGid = $gid; + } + } + + return $maxGid + 1; + } catch (Horde_Ldap_Exception $e) { + // Fallback to random high number + return 10000 + random_int(1, 10000); + } + } +} diff --git a/src/Service/LdapPrefsService.php b/src/Service/LdapPrefsService.php new file mode 100644 index 00000000..7eb01a87 --- /dev/null +++ b/src/Service/LdapPrefsService.php @@ -0,0 +1,296 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\HordeLdapService; +use Horde_Ldap; +use Horde_Ldap_Filter; +use Horde_Ldap_Exception; + +/** + * LDAP-based preferences service implementation + * + * Stores user preferences as LDAP attributes using hordePerson objectClass. + * Preference attributes are named: hordePref + * Example: hordePrefHordeTheme stores the 'theme' preference in 'horde' scope. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class LdapPrefsService implements PrefsService +{ + /** + * Constructor + * + * @param HordeLdapService $ldapService LDAP service for connections + * @param string $basedn Base DN for user searches + */ + public function __construct( + private HordeLdapService $ldapService, + private string $basedn + ) { + } + + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope Preference scope (application name) + * @param string $key Preference key + * @return string|null Preference value or null if not found + * @throws \RuntimeException If LDAP error occurs + */ + public function getValue(string $uid, string $scope, string $key): ?string + { + try { + $ldap = $this->ldapService->getAdapter(); + $userDN = $this->findUserDN($ldap, $uid); + + if (!$userDN) { + return null; + } + + // Search for hordePerson entry + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $attrName = $this->buildAttributeName($scope, $key); + + $search = $ldap->search($userDN, $filter, [ + 'attributes' => [$attrName], + 'scope' => 'sub', + ]); + + if ($search->count() === 0) { + // No hordePerson entry, check user entry directly + try { + $entry = $ldap->getEntry($userDN, ['attributes' => [$attrName]]); + return $entry->getValue($attrName, 'single'); + } catch (Horde_Ldap_Exception $e) { + return null; + } + } + + $entry = $search->shiftEntry(); + return $entry->getValue($attrName, 'single'); + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to get LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $e); + } + } + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope Preference scope + * @param string $key Preference key + * @param mixed $value Preference value + * @throws \RuntimeException If LDAP error occurs + */ + public function setValue(string $uid, string $scope, string $key, $value): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $userDN = $this->findUserDN($ldap, $uid); + + if (!$userDN) { + throw new \RuntimeException("User '$uid' not found in LDAP"); + } + + // Convert value to string for LDAP storage + $stringValue = is_string($value) ? $value : serialize($value); + + // Try to find existing hordePerson entry + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $search = $ldap->search($userDN, $filter, ['scope' => 'sub']); + + $attrName = $this->buildAttributeName($scope, $key); + + if ($search->count() === 0) { + // No hordePerson entry, modify user entry directly + $entry = $ldap->getEntry($userDN); + + // Add hordePerson objectClass if not present + $objectClasses = $entry->getValue('objectClass'); + if (!in_array('hordePerson', $objectClasses)) { + $objectClasses[] = 'hordePerson'; + $entry->replace(['objectClass' => $objectClasses], false); + } + + // Set preference attribute + $entry->replace([$attrName => $stringValue], false); + $entry->update(); + } else { + // Update existing hordePerson entry + $entry = $search->shiftEntry(); + $entry->replace([$attrName => $stringValue]); + $entry->update(); + } + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to set LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $e); + } + } + + /** + * Delete preference value + * + * @param string $uid User ID + * @param string $scope Preference scope + * @param string $key Preference key + * @throws \RuntimeException If LDAP error occurs + */ + public function deleteValue(string $uid, string $scope, string $key): void + { + try { + $ldap = $this->ldapService->getAdapter(); + $userDN = $this->findUserDN($ldap, $uid); + + if (!$userDN) { + return; // User not found, nothing to delete + } + + // Try to find hordePerson entry + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $search = $ldap->search($userDN, $filter, ['scope' => 'sub']); + + $attrName = $this->buildAttributeName($scope, $key); + + if ($search->count() > 0) { + $entry = $search->shiftEntry(); + $entry->delete([$attrName => []]); + $entry->update(); + } else { + // Try user entry directly + try { + $entry = $ldap->getEntry($userDN); + $entry->delete([$attrName => []]); + $entry->update(); + } catch (Horde_Ldap_Exception $e) { + // Attribute doesn't exist, that's fine + } + } + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to delete LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $e); + } + } + + /** + * Get all preferences in scope + * + * @param string $uid User ID + * @param string $scope Preference scope + * @return array Associative array of key => value + * @throws \RuntimeException If LDAP error occurs + */ + public function getAllInScope(string $uid, string $scope): array + { + try { + $ldap = $this->ldapService->getAdapter(); + $userDN = $this->findUserDN($ldap, $uid); + + if (!$userDN) { + return []; + } + + // Search for hordePerson entry with all attributes + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $search = $ldap->search($userDN, $filter, ['scope' => 'sub']); + + $prefs = []; + $prefix = 'hordePref' . $scope; + $prefixLen = strlen($prefix); + + if ($search->count() > 0) { + $entry = $search->shiftEntry(); + } else { + // Try user entry directly + try { + $entry = $ldap->getEntry($userDN); + } catch (Horde_Ldap_Exception $e) { + return []; + } + } + + // Extract preferences matching the scope prefix + foreach ($entry->getValues() as $attrName => $values) { + if (strpos($attrName, $prefix) === 0) { + $key = substr($attrName, $prefixLen); + // Lowercase first character for consistency + $key = lcfirst($key); + $prefs[$key] = is_array($values) ? $values[0] : $values; + } + } + + return $prefs; + } catch (Horde_Ldap_Exception $e) { + throw new \RuntimeException("Failed to get LDAP preferences in scope '$scope' for user '$uid': " . $e->getMessage(), 0, $e); + } + } + + /** + * Check if user exists in LDAP + * + * @param string $uid User ID + * @return bool True if user exists + */ + public function exists(string $uid): bool + { + try { + $ldap = $this->ldapService->getAdapter(); + $userDN = $this->findUserDN($ldap, $uid); + return $userDN !== null; + } catch (\Exception $e) { + return false; + } + } + + /** + * Find user DN by username + * + * @param Horde_Ldap $ldap LDAP connection + * @param string $uid User ID + * @return string|null User DN or null if not found + */ + private function findUserDN(Horde_Ldap $ldap, string $uid): ?string + { + try { + return $ldap->findUserDN($uid); + } catch (Horde_Ldap_Exception $e) { + // User not found + return null; + } + } + + /** + * Build LDAP attribute name for preference + * + * Format: hordePref + * Example: hordePrefHordeTheme for scope='horde', key='theme' + * + * @param string $scope Preference scope + * @param string $key Preference key + * @return string LDAP attribute name + */ + private function buildAttributeName(string $scope, string $key): string + { + // Capitalize first letter of scope and key for camelCase + $scope = ucfirst(strtolower($scope)); + $key = ucfirst($key); + + return "hordePref{$scope}{$key}"; + } +} diff --git a/src/Service/MockGroupService.php b/src/Service/MockGroupService.php new file mode 100644 index 00000000..ccb64be7 --- /dev/null +++ b/src/Service/MockGroupService.php @@ -0,0 +1,331 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\GroupNotFoundException; +use Horde\Core\Service\Exception\GroupExistsException; + +/** + * Mock group service implementation for testing + * + * Pure in-memory implementation with no external dependencies. + * Supports all CRUD operations. Can be pre-populated with fixture data. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class MockGroupService implements GroupService +{ + /** + * In-memory group storage + * + * @var array Map of group ID => group data + */ + private array $groups = []; + + /** + * Auto-increment counter for group IDs + * + * @var int + */ + private int $nextId = 1; + + /** + * Constructor + * + * @param array $fixtures Optional fixture data to pre-populate + * Format: [[name => '...', members => [...]], ...] + */ + public function __construct(array $fixtures = []) + { + foreach ($fixtures as $fixture) { + $this->create($fixture['name'] ?? 'Group'); + if (!empty($fixture['members'])) { + $id = 'group_' . ($this->nextId - 1); + $this->groups[$id]['members'] = $fixture['members']; + } + } + } + + /** + * List all groups with pagination + * + * @param int $page Page number (1-indexed) + * @param int $perPage Number of groups per page + * @return GroupListResult Paginated result + */ + public function listAll(int $page = 1, int $perPage = 50): GroupListResult + { + $total = count($this->groups); + $offset = ($page - 1) * $perPage; + + $slice = array_slice($this->groups, $offset, $perPage, true); + + $groups = []; + foreach ($slice as $id => $data) { + $groups[] = new GroupInfo( + id: $id, + name: $data['name'], + members: $data['members'] + ); + } + + return new GroupListResult( + groups: $groups, + total: $total, + page: $page, + perPage: $perPage, + hasNext: ($offset + $perPage) < $total, + hasPrev: $page > 1 + ); + } + + /** + * Get a specific group by identifier + * + * @param string $identifier Group ID or name + * @return GroupInfo Group information + * @throws GroupNotFoundException + */ + public function get(string $identifier): GroupInfo + { + // Try by ID first + if (isset($this->groups[$identifier])) { + return new GroupInfo( + id: $identifier, + name: $this->groups[$identifier]['name'], + members: $this->groups[$identifier]['members'] + ); + } + + // Try by name + foreach ($this->groups as $id => $data) { + if ($data['name'] === $identifier) { + return new GroupInfo( + id: $id, + name: $data['name'], + members: $data['members'] + ); + } + } + + throw new GroupNotFoundException("Group '$identifier' not found"); + } + + /** + * Check if a group exists + * + * @param string $identifier Group ID or name + * @return bool + */ + public function exists(string $identifier): bool + { + // Check by ID + if (isset($this->groups[$identifier])) { + return true; + } + + // Check by name + foreach ($this->groups as $data) { + if ($data['name'] === $identifier) { + return true; + } + } + + return false; + } + + /** + * Create a new group + * + * @param string $name Group name + * @return GroupInfo Created group + * @throws GroupExistsException + */ + public function create(string $name): GroupInfo + { + // Check if name already exists + foreach ($this->groups as $data) { + if ($data['name'] === $name) { + throw new GroupExistsException("Group '$name' already exists"); + } + } + + $id = 'group_' . $this->nextId++; + $this->groups[$id] = [ + 'name' => $name, + 'members' => [], + ]; + + return new GroupInfo( + id: $id, + name: $name, + members: [] + ); + } + + /** + * Delete a group + * + * @param string $identifier Group ID or name + * @throws GroupNotFoundException + */ + public function delete(string $identifier): void + { + // Try by ID first + if (isset($this->groups[$identifier])) { + unset($this->groups[$identifier]); + return; + } + + // Try by name + foreach ($this->groups as $id => $data) { + if ($data['name'] === $identifier) { + unset($this->groups[$id]); + return; + } + } + + throw new GroupNotFoundException("Group '$identifier' not found"); + } + + /** + * Get members of a group + * + * @param string $identifier Group ID or name + * @return array List of usernames + * @throws GroupNotFoundException + */ + public function getMembers(string $identifier): array + { + return $this->get($identifier)->members; + } + + /** + * Add a single member to a group (idempotent) + * + * @param string $identifier Group ID or name + * @param string $username Username to add + * @throws GroupNotFoundException + */ + public function addMember(string $identifier, string $username): void + { + $group = $this->get($identifier); // Throws if not found + $id = $this->resolveId($identifier); + + if (!in_array($username, $this->groups[$id]['members'])) { + $this->groups[$id]['members'][] = $username; + } + } + + /** + * Add multiple members to a group (idempotent) + * + * @param string $identifier Group ID or name + * @param array $usernames Usernames to add + * @throws GroupNotFoundException + */ + public function addMembers(string $identifier, array $usernames): void + { + foreach ($usernames as $username) { + $this->addMember($identifier, $username); + } + } + + /** + * Remove a single member from a group (idempotent) + * + * @param string $identifier Group ID or name + * @param string $username Username to remove + * @throws GroupNotFoundException + */ + public function removeMember(string $identifier, string $username): void + { + $group = $this->get($identifier); // Throws if not found + $id = $this->resolveId($identifier); + + $this->groups[$id]['members'] = array_values( + array_filter( + $this->groups[$id]['members'], + fn ($member) => $member !== $username + ) + ); + } + + /** + * Remove multiple members from a group (idempotent) + * + * @param string $identifier Group ID or name + * @param array $usernames Usernames to remove + * @throws GroupNotFoundException + */ + public function removeMembers(string $identifier, array $usernames): void + { + foreach ($usernames as $username) { + $this->removeMember($identifier, $username); + } + } + + /** + * Replace all members of a group + * + * @param string $identifier Group ID or name + * @param array $usernames New member list + * @throws GroupNotFoundException + */ + public function setMembers(string $identifier, array $usernames): void + { + $group = $this->get($identifier); // Throws if not found + $id = $this->resolveId($identifier); + + $this->groups[$id]['members'] = array_values(array_unique($usernames)); + } + + /** + * Check if backend is read-only + * + * @return bool Always false (mock supports writes) + */ + public function isReadOnly(): bool + { + return false; + } + + /** + * Resolve identifier to internal ID + * + * @param string $identifier Group ID or name + * @return string Internal group ID + * @throws GroupNotFoundException + */ + private function resolveId(string $identifier): string + { + if (isset($this->groups[$identifier])) { + return $identifier; + } + + foreach ($this->groups as $id => $data) { + if ($data['name'] === $identifier) { + return $id; + } + } + + throw new GroupNotFoundException("Group '$identifier' not found"); + } +} diff --git a/src/Service/MongoPrefsService.php b/src/Service/MongoPrefsService.php new file mode 100644 index 00000000..26213918 --- /dev/null +++ b/src/Service/MongoPrefsService.php @@ -0,0 +1,265 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Mongo_Client; +use MongoCollection; +use MongoException; +use RuntimeException; + +/** + * MongoDB-based preferences storage service + * + * Stores preferences as documents in MongoDB collection. + * Document format: {uid, scope, key, value} + * + * Requires: mongodb PHP extension, Horde_Mongo_Client + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class MongoPrefsService implements PrefsService +{ + /** + * MongoDB collection handle + * + * @var MongoCollection + */ + private MongoCollection $collection; + + /** + * Constructor + * + * @param Horde_Mongo_Client $mongoClient MongoDB client + * @param string $collectionName Collection name (default: horde_prefs) + * @throws RuntimeException If MongoDB connection fails + */ + public function __construct( + private Horde_Mongo_Client $mongoClient, + private string $collectionName = 'horde_prefs' + ) { + try { + // Select collection (triggers connection) + $this->collection = $this->mongoClient->selectCollection( + null, // Use default database from client config + $this->collectionName + ); + + // Ensure indexes for performance + $this->ensureIndexes(); + } catch (MongoException $e) { + throw new RuntimeException( + 'Failed to connect to MongoDB: ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Create indexes for efficient querying + */ + private function ensureIndexes(): void + { + try { + // Compound index for lookups: (uid, scope, key) + $this->collection->ensureIndex( + ['uid' => 1, 'scope' => 1, 'key' => 1], + ['unique' => true, 'background' => true] + ); + + // Index for scope queries + $this->collection->ensureIndex( + ['uid' => 1, 'scope' => 1], + ['background' => true] + ); + } catch (MongoException $e) { + // Index creation failure is non-fatal, but log it + // In production, this would use Horde_Log + error_log('Failed to create MongoDB indexes: ' . $e->getMessage()); + } + } + + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return mixed|null Value or null if not found + * @throws RuntimeException On MongoDB error + */ + public function getValue(string $uid, string $scope, string $key) + { + try { + $doc = $this->collection->findOne([ + 'uid' => $uid, + 'scope' => $scope, + 'key' => $key, + ]); + + if ($doc === null) { + return null; + } + + // Handle MongoBinData for binary values (legacy compatibility) + $value = $doc['value']; + if ($value instanceof \MongoBinData) { + return $value->bin; + } + + return $value; + } catch (MongoException $e) { + throw new RuntimeException( + 'MongoDB query failed: ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @param mixed $value Preference value + * @throws RuntimeException On MongoDB error + */ + public function setValue(string $uid, string $scope, string $key, $value): void + { + try { + $query = [ + 'uid' => $uid, + 'scope' => $scope, + 'key' => $key, + ]; + + $document = array_merge($query, [ + 'value' => $value, + ]); + + // Upsert: insert if not exists, update if exists + $this->collection->update( + $query, + $document, + ['upsert' => true] + ); + } catch (MongoException $e) { + throw new RuntimeException( + 'MongoDB update failed: ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Delete preference + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @throws RuntimeException On MongoDB error + */ + public function deleteValue(string $uid, string $scope, string $key): void + { + try { + $this->collection->remove([ + 'uid' => $uid, + 'scope' => $scope, + 'key' => $key, + ]); + } catch (MongoException $e) { + throw new RuntimeException( + 'MongoDB delete failed: ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Get all preferences for user in scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Associative array of key => value + * @throws RuntimeException On MongoDB error + */ + public function getAllInScope(string $uid, string $scope): array + { + try { + $cursor = $this->collection->find([ + 'uid' => $uid, + 'scope' => $scope, + ]); + + $result = []; + foreach ($cursor as $doc) { + $key = $doc['key']; + $value = $doc['value']; + + // Handle MongoBinData + if ($value instanceof \MongoBinData) { + $value = $value->bin; + } + + $result[$key] = $value; + } + + return $result; + } catch (MongoException $e) { + throw new RuntimeException( + 'MongoDB query failed: ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Check if preference exists + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool + * @throws RuntimeException On MongoDB error + */ + public function exists(string $uid, string $scope, string $key): bool + { + try { + $count = $this->collection->count([ + 'uid' => $uid, + 'scope' => $scope, + 'key' => $key, + ]); + + return $count > 0; + } catch (MongoException $e) { + throw new RuntimeException( + 'MongoDB query failed: ' . $e->getMessage(), + 0, + $e + ); + } + } +} diff --git a/src/Service/NullPermissionService.php b/src/Service/NullPermissionService.php new file mode 100644 index 00000000..cdf6b8da --- /dev/null +++ b/src/Service/NullPermissionService.php @@ -0,0 +1,154 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\PermissionNotFoundException; + +/** + * Null permission service implementation + * + * No-op implementation that denies all permissions. + * Used when permissions are disabled in configuration. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class NullPermissionService implements PermissionService +{ + /** + * List all permissions + * + * @return array Empty array + */ + public function listAll(): array + { + return []; + } + + /** + * Get permission tree structure + * + * @return array Empty array + */ + public function getTree(): array + { + return []; + } + + /** + * Get permission by name + * + * @param string $name Permission name + * @return array Permission details + * @throws PermissionNotFoundException Always thrown + */ + public function get(string $name): array + { + throw new PermissionNotFoundException('Permissions are disabled'); + } + + /** + * Check if permission exists + * + * @param string $name Permission name + * @return bool Always false + */ + public function exists(string $name): bool + { + return false; + } + + /** + * Get parent permissions + * + * @param string $name Child permission name + * @return array Empty array + */ + public function getParents(string $name): array + { + return []; + } + + /** + * Create new permission + * + * @param string $name Permission name + * @param string $type Permission type + * @param array $data Permission data + * @return void + */ + public function create(string $name, string $type = 'matrix', array $data = []): void + { + // No-op + } + + /** + * Update existing permission + * + * @param string $name Permission name + * @param array $data Permission data + * @return void + */ + public function update(string $name, array $data): void + { + // No-op + } + + /** + * Delete permission + * + * @param string $name Permission name + * @param bool $force If true, also delete children + * @return void + */ + public function delete(string $name, bool $force = false): void + { + // No-op + } + + /** + * Check if user has specific permission + * + * @param string $name Permission name + * @param string $user Username + * @param array $requiredPerms Required permission flags + * @return bool Always false + */ + public function hasPermission(string $name, string $user, array $requiredPerms): bool + { + return false; + } + + /** + * Get user's permissions for a resource + * + * @param string $name Permission name + * @param string $user Username + * @return array All permissions denied + */ + public function getUserPermissions(string $name, string $user): array + { + return [ + 'show' => false, + 'read' => false, + 'edit' => false, + 'delete' => false, + ]; + } +} diff --git a/src/Service/NullPrefsService.php b/src/Service/NullPrefsService.php new file mode 100644 index 00000000..92741eab --- /dev/null +++ b/src/Service/NullPrefsService.php @@ -0,0 +1,103 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * Null preferences service implementation + * + * In-memory only implementation for testing or session-based prefs. + * Does not persist any data. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class NullPrefsService implements PrefsService +{ + /** + * In-memory storage + * + * @var array + */ + private array $storage = []; + + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return mixed|null Preference value or null if not found + */ + public function getValue(string $uid, string $scope, string $key) + { + return $this->storage[$uid][$scope][$key] ?? null; + } + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @param mixed $value Preference value + * @return void + */ + public function setValue(string $uid, string $scope, string $key, $value): void + { + $this->storage[$uid][$scope][$key] = $value; + } + + /** + * Delete preference + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return void + */ + public function deleteValue(string $uid, string $scope, string $key): void + { + unset($this->storage[$uid][$scope][$key]); + } + + /** + * Get all preferences for user in scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Associative array of key => value + */ + public function getAllInScope(string $uid, string $scope): array + { + return $this->storage[$uid][$scope] ?? []; + } + + /** + * Check if preference exists + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool True if preference exists + */ + public function exists(string $uid, string $scope, string $key): bool + { + return isset($this->storage[$uid][$scope][$key]); + } +} diff --git a/src/Service/PermissionService.php b/src/Service/PermissionService.php new file mode 100644 index 00000000..1c4dcdb4 --- /dev/null +++ b/src/Service/PermissionService.php @@ -0,0 +1,135 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\PermissionNotFoundException; + +/** + * Permission service interface + * + * Modern service interface for permission management, wrapping legacy + * Horde_Perms backends (SQL, Null). + * + * Handles hierarchical permission trees with colon-separated names + * (e.g., 'horde:turba:contact:123'). + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface PermissionService +{ + /** + * List all permissions + * + * Returns flat list of all permission names in the system. + * + * @return array Array of permission names + */ + public function listAll(): array; + + /** + * Get permission tree structure + * + * Returns hierarchical tree of permissions with parent-child relationships. + * + * @return array Tree structure mapping IDs to names + */ + public function getTree(): array; + + /** + * Get permission by name + * + * Returns permission details with expanded permission matrix (boolean flags + * instead of bitmasks). + * + * @param string $name Permission name (e.g., 'horde:turba:contact:123') + * @return array Permission details with expanded matrix + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function get(string $name): array; + + /** + * Check if permission exists + * + * @param string $name Permission name + * @return bool True if permission exists + */ + public function exists(string $name): bool; + + /** + * Get parent permissions for a permission + * + * Returns array of parent permission names in hierarchical order. + * + * @param string $name Child permission name + * @return array Array of parent permission names + */ + public function getParents(string $name): array; + + /** + * Create new permission + * + * @param string $name Permission name + * @param string $type Permission type ('matrix' or 'boolean') + * @param array $data Permission data (users, groups, default, etc.) + * @return void + */ + public function create(string $name, string $type = 'matrix', array $data = []): void; + + /** + * Update existing permission + * + * @param string $name Permission name + * @param array $data Permission data (users, groups, default, etc.) + * @return void + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function update(string $name, array $data): void; + + /** + * Delete permission + * + * @param string $name Permission name + * @param bool $force If true, also delete all child permissions + * @return void + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function delete(string $name, bool $force = false): void; + + /** + * Check if user has specific permission + * + * @param string $name Permission name + * @param string $user Username + * @param array $requiredPerms Required permission flags (e.g., ['read', 'edit']) + * @return bool True if user has all required permissions + */ + public function hasPermission(string $name, string $user, array $requiredPerms): bool; + + /** + * Get user's permissions for a resource + * + * Returns expanded permission flags. + * + * @param string $name Permission name + * @param string $user Username + * @return array Permission flags (show, read, edit, delete) + */ + public function getUserPermissions(string $name, string $user): array; +} diff --git a/src/Service/PrefsService.php b/src/Service/PrefsService.php new file mode 100644 index 00000000..194d78aa --- /dev/null +++ b/src/Service/PrefsService.php @@ -0,0 +1,84 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +/** + * Preferences service interface + * + * Provides access to user preferences storage backends. + * Prefs are scoped by user AND application. + * + * For identities/user management, we focus on storage/retrieval only. + * Schema, defaults, locking, and enforced prefs are out of scope. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface PrefsService +{ + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope App name ('horde', 'imp', etc.) + * @param string $key Preference key + * @return mixed|null Preference value or null if not found + */ + public function getValue(string $uid, string $scope, string $key); + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @param mixed $value Preference value (will be serialized if needed) + * @return void + */ + public function setValue(string $uid, string $scope, string $key, $value): void; + + /** + * Delete preference + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return void + */ + public function deleteValue(string $uid, string $scope, string $key): void; + + /** + * Get all preferences for user in scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Associative array of key => value + */ + public function getAllInScope(string $uid, string $scope): array; + + /** + * Check if preference exists + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool True if preference exists + */ + public function exists(string $uid, string $scope, string $key): bool; +} diff --git a/src/Service/SqlGroupService.php b/src/Service/SqlGroupService.php new file mode 100644 index 00000000..c71ecf97 --- /dev/null +++ b/src/Service/SqlGroupService.php @@ -0,0 +1,319 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Group_Base; +use Horde\Core\Service\Exception\GroupNotFoundException; +use Horde\Core\Service\Exception\GroupExistsException; + +/** + * SQL-based group service implementation + * + * Wraps legacy Horde_Group_Base backends with modern service interface. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SqlGroupService implements GroupService +{ + /** + * Constructor + * + * @param Horde_Group_Base $backend Legacy group backend (Horde_Group_Sql, etc.) + */ + public function __construct( + private Horde_Group_Base $backend + ) { + } + + /** + * List all groups with pagination + * + * @param int $page Page number (1-indexed) + * @param int $perPage Number of groups per page + * @return GroupListResult Paginated result + */ + public function listAll(int $page = 1, int $perPage = 50): GroupListResult + { + // Get all groups from backend (returns [gid => name]) + $allGroups = $this->backend->listAll(); + $total = count($allGroups); + + // Calculate pagination + $offset = ($page - 1) * $perPage; + $slice = array_slice($allGroups, $offset, $perPage, true); + + // Build GroupInfo objects + $groups = []; + foreach ($slice as $gid => $name) { + $members = $this->backend->listUsers($gid); + $groups[] = new GroupInfo( + id: (string) $gid, + name: $name, + members: $members + ); + } + + return new GroupListResult( + groups: $groups, + total: $total, + page: $page, + perPage: $perPage, + hasNext: ($offset + $perPage) < $total, + hasPrev: $page > 1 + ); + } + + /** + * Get a specific group by identifier + * + * @param string $identifier Group ID or name + * @return GroupInfo Group information + * @throws GroupNotFoundException If group not found + */ + public function get(string $identifier): GroupInfo + { + $gid = $this->resolveIdentifier($identifier); + $data = $this->backend->getData($gid); + $members = $this->backend->listUsers($gid); + + // Extract name - getData() returns array with 'group_name' or 'name' key + $name = $data['group_name'] ?? $data['name'] ?? ''; + + return new GroupInfo( + id: (string) $gid, + name: $name, + members: $members + ); + } + + /** + * Check if a group exists + * + * @param string $identifier Group ID or name + * @return bool True if exists + */ + public function exists(string $identifier): bool + { + try { + $this->resolveIdentifier($identifier); + return true; + } catch (GroupNotFoundException $e) { + return false; + } + } + + /** + * Create a new group + * + * @param string $name Group name + * @return GroupInfo Created group + * @throws GroupExistsException If group already exists + */ + public function create(string $name): GroupInfo + { + // Check if group with this name already exists + try { + $existing = $this->get($name); + throw new GroupExistsException("Group already exists: $name"); + } catch (GroupNotFoundException $e) { + // Good, doesn't exist yet + } + + // Create group (email parameter null) + $gid = $this->backend->create($name, null); + + // Return the created group + return $this->get((string) $gid); + } + + /** + * Delete a group + * + * @param string $identifier Group ID or name + * @return void + * @throws GroupNotFoundException If group not found + */ + public function delete(string $identifier): void + { + $gid = $this->resolveIdentifier($identifier); + $this->backend->remove($gid); + } + + /** + * Get members of a group + * + * @param string $identifier Group ID or name + * @return array List of usernames + * @throws GroupNotFoundException If group not found + */ + public function getMembers(string $identifier): array + { + $gid = $this->resolveIdentifier($identifier); + return $this->backend->listUsers($gid); + } + + /** + * Add a single member to a group (idempotent) + * + * @param string $identifier Group ID or name + * @param string $username Username to add + * @return void + * @throws GroupNotFoundException If group not found + */ + public function addMember(string $identifier, string $username): void + { + $gid = $this->resolveIdentifier($identifier); + + // Idempotent: check if already member + $members = $this->backend->listUsers($gid); + if (in_array($username, $members)) { + return; // Already a member, no-op + } + + $this->backend->addUser($gid, $username); + } + + /** + * Add multiple members to a group (idempotent, batch) + * + * @param string $identifier Group ID or name + * @param array $usernames Usernames to add + * @return void + * @throws GroupNotFoundException If group not found + */ + public function addMembers(string $identifier, array $usernames): void + { + $gid = $this->resolveIdentifier($identifier); + $currentMembers = $this->backend->listUsers($gid); + + foreach ($usernames as $username) { + if (!in_array($username, $currentMembers)) { + $this->backend->addUser($gid, $username); + } + } + } + + /** + * Remove a single member from a group (idempotent) + * + * @param string $identifier Group ID or name + * @param string $username Username to remove + * @return void + * @throws GroupNotFoundException If group not found + */ + public function removeMember(string $identifier, string $username): void + { + $gid = $this->resolveIdentifier($identifier); + + // Idempotent: check if member exists + $members = $this->backend->listUsers($gid); + if (!in_array($username, $members)) { + return; // Not a member, no-op + } + + $this->backend->removeUser($gid, $username); + } + + /** + * Remove multiple members from a group (idempotent, batch) + * + * @param string $identifier Group ID or name + * @param array $usernames Usernames to remove + * @return void + * @throws GroupNotFoundException If group not found + */ + public function removeMembers(string $identifier, array $usernames): void + { + $gid = $this->resolveIdentifier($identifier); + $currentMembers = $this->backend->listUsers($gid); + + foreach ($usernames as $username) { + if (in_array($username, $currentMembers)) { + $this->backend->removeUser($gid, $username); + } + } + } + + /** + * Replace all members of a group + * + * @param string $identifier Group ID or name + * @param array $usernames New member list + * @return void + * @throws GroupNotFoundException If group not found + */ + public function setMembers(string $identifier, array $usernames): void + { + $gid = $this->resolveIdentifier($identifier); + $currentMembers = $this->backend->listUsers($gid); + + // Remove members not in new list + foreach ($currentMembers as $member) { + if (!in_array($member, $usernames)) { + $this->backend->removeUser($gid, $member); + } + } + + // Add members not in current list + foreach ($usernames as $username) { + if (!in_array($username, $currentMembers)) { + $this->backend->addUser($gid, $username); + } + } + } + + /** + * Check if backend is read-only + * + * @return bool True if read-only + */ + public function isReadOnly(): bool + { + return $this->backend->readOnly(); + } + + /** + * Resolve identifier to group ID + * + * Tries to resolve identifier as: + * 1. Numeric ID (if backend->exists() returns true) + * 2. Group name (via exact match in search results) + * + * @param string $identifier Group ID or name + * @return string Group ID + * @throws GroupNotFoundException If not found + */ + private function resolveIdentifier(string $identifier): string + { + // If numeric, try as ID first + if (is_numeric($identifier) && $this->backend->exists($identifier)) { + return $identifier; + } + + // Otherwise search by name (exact match only) + foreach ($this->backend->search($identifier) as $gid => $name) { + if ($name === $identifier) { + return (string) $gid; + } + } + + throw new GroupNotFoundException("Group not found: $identifier"); + } +} diff --git a/src/Service/SqlPermissionService.php b/src/Service/SqlPermissionService.php new file mode 100644 index 00000000..ed848e19 --- /dev/null +++ b/src/Service/SqlPermissionService.php @@ -0,0 +1,551 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Service\Exception\PermissionNotFoundException; +use Horde_Perms_Base; +use Horde_Perms_Permission; +use Horde_Perms_Exception; +use Horde_Perms; + +/** + * SQL-based permission service implementation + * + * Wraps Horde_Perms_Sql backend with modern API, expanding bitmask + * permissions to boolean flags and resolving group IDs to names. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SqlPermissionService implements PermissionService +{ + /** + * Constructor + * + * @param Horde_Perms_Base $backend Legacy permissions backend + * @param GroupService $groupService Group service for ID/name resolution + */ + public function __construct( + private Horde_Perms_Base $backend, + private GroupService $groupService + ) { + } + + /** + * List all permissions + * + * @return array Array of permission names + */ + public function listAll(): array + { + $tree = $this->backend->getTree(); + // Remove ROOT pseudo-permission + unset($tree[Horde_Perms::ROOT]); + return array_values($tree); + } + + /** + * Get permission tree structure + * + * @return array Tree structure mapping IDs to names + */ + public function getTree(): array + { + return $this->backend->getTree(); + } + + /** + * Get permission by name + * + * @param string $name Permission name + * @return array Permission details with expanded matrix + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function get(string $name): array + { + try { + $perm = $this->backend->getPermission($name); + } catch (Horde_Perms_Exception $e) { + throw new PermissionNotFoundException("Permission not found: {$name}", 0, $e); + } + + $data = $perm->getData(); + $type = $data['type'] ?? 'matrix'; + $isMatrix = ($type === 'matrix'); + + return [ + 'name' => $perm->getName(), + 'type' => $type, + 'users' => $isMatrix + ? $this->expandUsersToArray($data['users'] ?? []) + : $this->expandUsersToArraySimple($data['users'] ?? []), + 'groups' => $isMatrix + ? $this->expandGroupsToArray($data['groups'] ?? []) + : $this->expandGroupsToArraySimple($data['groups'] ?? []), + 'default' => $isMatrix + ? $this->expandPermissionBits($data['default'] ?? 0) + : ($data['default'] ?? null), + 'guest' => $isMatrix + ? $this->expandPermissionBits($data['guest'] ?? 0) + : ($data['guest'] ?? null), + 'creator' => $isMatrix + ? $this->expandPermissionBits($data['creator'] ?? 0) + : ($data['creator'] ?? null), + 'parents' => $this->getParents($name), + ]; + } + + /** + * Check if permission exists + * + * @param string $name Permission name + * @return bool True if permission exists + */ + public function exists(string $name): bool + { + return $this->backend->exists($name); + } + + /** + * Get parent permissions + * + * @param string $name Child permission name + * @return array Array of parent permission names + */ + public function getParents(string $name): array + { + try { + return $this->backend->getParents($name); + } catch (Horde_Perms_Exception $e) { + return []; + } + } + + /** + * Create new permission + * + * @param string $name Permission name + * @param string $type Permission type + * @param array $data Permission data + * @return void + */ + public function create(string $name, string $type = 'matrix', array $data = []): void + { + $perm = $this->backend->newPermission($name, $type); + $isMatrix = ($type === 'matrix'); + + // For matrix type, compress boolean flags to bitmasks first + // For non-matrix types, pass values through directly + // Note: setPerm() will handle bitmask operations internally based on type + + // Set users permissions + if (isset($data['users'])) { + $userData = $isMatrix + ? $this->compressUsersFromArray($data['users']) + : $this->compressUsersFromArraySimple($data['users']); + + foreach ($userData as $username => $value) { + $perm->setPerm(['class' => 'users', 'name' => $username], $value, false); + } + } + + // Set group permissions + if (isset($data['groups'])) { + $groupData = $isMatrix + ? $this->compressGroupsFromArray($data['groups']) + : $this->compressGroupsFromArraySimple($data['groups']); + + foreach ($groupData as $gid => $value) { + $perm->setPerm(['class' => 'groups', 'name' => (string)$gid], $value, false); + } + } + + // Set default permission + if (isset($data['default'])) { + $value = $isMatrix + ? $this->compressPermissionBits($data['default']) + : $data['default']; + $perm->setPerm('default', $value, false); + } + + // Set guest permission + if (isset($data['guest'])) { + $value = $isMatrix + ? $this->compressPermissionBits($data['guest']) + : $data['guest']; + $perm->setPerm('guest', $value, false); + } + + // Set creator permission + if (isset($data['creator'])) { + $value = $isMatrix + ? $this->compressPermissionBits($data['creator']) + : $data['creator']; + $perm->setPerm('creator', $value, false); + } + + $this->backend->addPermission($perm); + } + + /** + * Update existing permission + * + * @param string $name Permission name + * @param array $data Permission data + * @return void + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function update(string $name, array $data): void + { + try { + $perm = $this->backend->getPermission($name); + } catch (Horde_Perms_Exception $e) { + throw new PermissionNotFoundException("Permission not found: {$name}", 0, $e); + } + + // Get current permission data and type + $permData = $perm->getData(); + $type = $permData['type'] ?? 'matrix'; + $isMatrix = ($type === 'matrix'); + + // Update permissions - compress if matrix type + if (isset($data['users'])) { + $permData['users'] = $isMatrix + ? $this->compressUsersFromArray($data['users']) + : $this->compressUsersFromArraySimple($data['users']); + } + + if (isset($data['groups'])) { + $permData['groups'] = $isMatrix + ? $this->compressGroupsFromArray($data['groups']) + : $this->compressGroupsFromArraySimple($data['groups']); + } + + if (isset($data['default'])) { + $permData['default'] = $isMatrix + ? $this->compressPermissionBits($data['default']) + : $data['default']; + } + + if (isset($data['guest'])) { + $permData['guest'] = $isMatrix + ? $this->compressPermissionBits($data['guest']) + : $data['guest']; + } + + if (isset($data['creator'])) { + $permData['creator'] = $isMatrix + ? $this->compressPermissionBits($data['creator']) + : $data['creator']; + } + + $perm->setData($permData); + $perm->save(); + } + + /** + * Delete permission + * + * @param string $name Permission name + * @param bool $force If true, also delete children + * @return void + * @throws PermissionNotFoundException If permission doesn't exist + */ + public function delete(string $name, bool $force = false): void + { + try { + $perm = $this->backend->getPermission($name); + $this->backend->removePermission($perm, $force); + } catch (Horde_Perms_Exception $e) { + throw new PermissionNotFoundException("Permission not found: {$name}", 0, $e); + } + } + + /** + * Check if user has specific permission + * + * @param string $name Permission name + * @param string $user Username + * @param array $requiredPerms Required permission flags + * @return bool True if user has all required permissions + */ + public function hasPermission(string $name, string $user, array $requiredPerms): bool + { + $userPerms = $this->getUserPermissions($name, $user); + + foreach ($requiredPerms as $perm) { + if (empty($userPerms[$perm])) { + return false; + } + } + + return true; + } + + /** + * Get user's permissions for a resource + * + * @param string $name Permission name + * @param string $user Username + * @return array Permission flags + */ + public function getUserPermissions(string $name, string $user): array + { + try { + $permBits = $this->backend->getPermissions($name, $user); + return $this->expandPermissionBits($permBits); + } catch (Horde_Perms_Exception $e) { + return ['show' => false, 'read' => false, 'edit' => false, 'delete' => false]; + } + } + + /** + * Expand users to array format (matrix type) + * + * @param array $users Map of user_id => bitmask + * @return array Array of user objects + */ + private function expandUsersToArray(array $users): array + { + $result = []; + foreach ($users as $userId => $bits) { + $result[] = [ + 'id' => $userId, + 'permissions' => $this->expandPermissionBits($bits), + ]; + } + return $result; + } + + /** + * Expand users to array format (non-matrix type) + * + * @param array $users Map of user_id => value + * @return array Array of user objects + */ + private function expandUsersToArraySimple(array $users): array + { + $result = []; + foreach ($users as $userId => $value) { + $result[] = [ + 'id' => $userId, + 'permissions' => $value, + ]; + } + return $result; + } + + /** + * Expand groups to array format (matrix type) + * + * Includes group name when resolvable via GroupService. + * Loose coupling: GroupService failure doesn't fail permission read. + * + * @param array $groups Map of group_id => bitmask + * @return array Array of group objects + */ + private function expandGroupsToArray(array $groups): array + { + $result = []; + foreach ($groups as $gid => $bits) { + $groupName = null; + try { + $groupInfo = $this->groupService->get((string)$gid); + $groupName = $groupInfo->name; + } catch (\Exception $e) { + // Group not found - name stays null (loose coupling) + } + + $result[] = [ + 'id' => (string)$gid, + 'name' => $groupName, + 'permissions' => $this->expandPermissionBits($bits), + ]; + } + return $result; + } + + /** + * Expand groups to array format (non-matrix type) + * + * @param array $groups Map of group_id => value + * @return array Array of group objects + */ + private function expandGroupsToArraySimple(array $groups): array + { + $result = []; + foreach ($groups as $gid => $value) { + $groupName = null; + try { + $groupInfo = $this->groupService->get((string)$gid); + $groupName = $groupInfo->name; + } catch (\Exception $e) { + // Group not found - name stays null + } + + $result[] = [ + 'id' => (string)$gid, + 'name' => $groupName, + 'permissions' => $value, + ]; + } + return $result; + } + + /** + * Compress users from array format (matrix type) + * + * @param array $users Array of user objects + * @return array Map of user_id => bitmask + */ + private function compressUsersFromArray(array $users): array + { + $compressed = []; + foreach ($users as $user) { + if (!isset($user['id']) || !isset($user['permissions'])) { + continue; // Skip malformed entries + } + $compressed[$user['id']] = $this->compressPermissionBits($user['permissions']); + } + return $compressed; + } + + /** + * Compress users from array format (non-matrix type) + * + * @param array $users Array of user objects + * @return array Map of user_id => value + */ + private function compressUsersFromArraySimple(array $users): array + { + $compressed = []; + foreach ($users as $user) { + if (!isset($user['id']) || !isset($user['permissions'])) { + continue; + } + $compressed[$user['id']] = $user['permissions']; + } + return $compressed; + } + + /** + * Compress groups from array format (matrix type) + * + * Uses 'id' field, ignores 'name' (loose coupling). + * If name is provided without id, try to resolve via GroupService. + * + * @param array $groups Array of group objects + * @return array Map of group_id => bitmask + */ + private function compressGroupsFromArray(array $groups): array + { + $compressed = []; + foreach ($groups as $group) { + $gid = null; + + // Prefer explicit ID + if (isset($group['id'])) { + $gid = $group['id']; + } elseif (isset($group['name'])) { + // Try to resolve name to ID + try { + $groupInfo = $this->groupService->get($group['name']); + $gid = $groupInfo->id; + } catch (\Exception $e) { + continue; // Cannot resolve, skip + } + } + + if ($gid && isset($group['permissions'])) { + $compressed[$gid] = $this->compressPermissionBits($group['permissions']); + } + } + return $compressed; + } + + /** + * Compress groups from array format (non-matrix type) + * + * @param array $groups Array of group objects + * @return array Map of group_id => value + */ + private function compressGroupsFromArraySimple(array $groups): array + { + $compressed = []; + foreach ($groups as $group) { + $gid = null; + + if (isset($group['id'])) { + $gid = $group['id']; + } elseif (isset($group['name'])) { + try { + $groupInfo = $this->groupService->get($group['name']); + $gid = $groupInfo->id; + } catch (\Exception $e) { + continue; + } + } + + if ($gid && isset($group['permissions'])) { + $compressed[$gid] = $group['permissions']; + } + } + return $compressed; + } + + /** + * Expand permission bitmask to boolean flags + * + * @param int $bits Permission bitmask + * @return array Boolean flags + */ + private function expandPermissionBits(int $bits): array + { + return [ + 'show' => (bool)($bits & Horde_Perms::SHOW), + 'read' => (bool)($bits & Horde_Perms::READ), + 'edit' => (bool)($bits & Horde_Perms::EDIT), + 'delete' => (bool)($bits & Horde_Perms::DELETE), + ]; + } + + /** + * Compress boolean flags to permission bitmask + * + * @param array $flags Boolean flags + * @return int Permission bitmask + */ + private function compressPermissionBits(array $flags): int + { + $bits = 0; + if (!empty($flags['show'])) { + $bits |= Horde_Perms::SHOW; + } + if (!empty($flags['read'])) { + $bits |= Horde_Perms::READ; + } + if (!empty($flags['edit'])) { + $bits |= Horde_Perms::EDIT; + } + if (!empty($flags['delete'])) { + $bits |= Horde_Perms::DELETE; + } + return $bits; + } +} diff --git a/src/Service/SqlPrefsService.php b/src/Service/SqlPrefsService.php new file mode 100644 index 00000000..fba10cc7 --- /dev/null +++ b/src/Service/SqlPrefsService.php @@ -0,0 +1,181 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Db_Adapter; +use Horde_Prefs; +use Horde_Prefs_Storage_Sql; +use Horde_Prefs_Scope; + +/** + * SQL-based preferences service implementation + * + * Provides prefs storage using SQL backend (horde_prefs table). + * Wraps legacy Horde_Prefs and Horde_Prefs_Storage_Sql classes + * with modern DI-friendly interface. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SqlPrefsService implements PrefsService +{ + /** + * Constructor + * + * @param Horde_Db_Adapter $db Database adapter + * @param string $table Prefs table name (default: 'horde_prefs') + */ + public function __construct( + private Horde_Db_Adapter $db, + private string $table = 'horde_prefs' + ) { + } + + /** + * Get preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return mixed|null Preference value or null if not found + */ + public function getValue(string $uid, string $scope, string $key) + { + $prefs = $this->createPrefs($uid, $scope); + + // Use array access to check existence and get value + if (!isset($prefs[$key])) { + return null; + } + + return $prefs->getValue($key); + } + + /** + * Set preference value + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @param mixed $value Preference value + * @return void + */ + public function setValue(string $uid, string $scope, string $key, $value): void + { + // Create storage backend directly to bypass Horde_Prefs existence checks + $storage = new Horde_Prefs_Storage_Sql($uid, [ + 'db' => $this->db, + 'table' => $this->table, + ]); + + // Create a scope object and set the value + $scopeObj = new Horde_Prefs_Scope($scope); + $scopeObj->set($key, $value); + + // Store directly to database + $storage->store($scopeObj); + } + + /** + * Delete preference + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return void + */ + public function deleteValue(string $uid, string $scope, string $key): void + { + // Delete directly from storage backend + $storage = new Horde_Prefs_Storage_Sql($uid, [ + 'db' => $this->db, + 'table' => $this->table, + ]); + + // Use storage's remove method + $storage->remove($scope, $key); + } + + /** + * Get all preferences for user in scope + * + * @param string $uid User ID + * @param string $scope App name + * @return array Associative array of key => value + */ + public function getAllInScope(string $uid, string $scope): array + { + try { + $query = 'SELECT pref_name, pref_value FROM ' . $this->table . + ' WHERE pref_uid = ? AND pref_scope = ?'; + $result = $this->db->select($query, [$uid, $scope]); + + $prefs = []; + $columns = $this->db->columns($this->table); + + foreach ($result as $row) { + $key = trim($row['pref_name']); + $value = $columns['pref_value']->binaryToString($row['pref_value']); + $prefs[$key] = $value; + } + + return $prefs; + } catch (\Horde_Db_Exception $e) { + // Return empty array on error + return []; + } + } + + /** + * Check if preference exists + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool True if preference exists + */ + public function exists(string $uid, string $scope, string $key): bool + { + $prefs = $this->createPrefs($uid, $scope); + return isset($prefs[$key]); + } + + /** + * Create Horde_Prefs instance for user/scope + * + * @param string $uid User ID + * @param string $scope App name + * @return Horde_Prefs Prefs instance + */ + private function createPrefs(string $uid, string $scope): Horde_Prefs + { + // Create SQL storage backend + $storage = new Horde_Prefs_Storage_Sql($uid, [ + 'db' => $this->db, + 'table' => $this->table, + ]); + + // Create prefs object with storage + return new Horde_Prefs($scope, [ + $storage, + ], [ + 'user' => $uid, + ]); + } +} diff --git a/src/Service/StandardHordeDbService.php b/src/Service/StandardHordeDbService.php new file mode 100644 index 00000000..ae8f83f8 --- /dev/null +++ b/src/Service/StandardHordeDbService.php @@ -0,0 +1,53 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Db_Adapter; + +/** + * Standard database service implementation for Horde + * + * Simple wrapper around Horde_Db_Adapter to provide + * service interface for dependency injection. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class StandardHordeDbService implements HordeDbService +{ + /** + * Constructor + * + * @param Horde_Db_Adapter $adapter Database adapter instance + */ + public function __construct( + private Horde_Db_Adapter $adapter + ) { + } + + /** + * Get database adapter + * + * @return Horde_Db_Adapter Database adapter instance + */ + public function getAdapter(): Horde_Db_Adapter + { + return $this->adapter; + } +} diff --git a/src/Service/StandardHordeLdapService.php b/src/Service/StandardHordeLdapService.php new file mode 100644 index 00000000..4ead7ed2 --- /dev/null +++ b/src/Service/StandardHordeLdapService.php @@ -0,0 +1,53 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde_Ldap; + +/** + * Standard LDAP service implementation for Horde + * + * Simple wrapper around Horde_Ldap to provide + * service interface for dependency injection. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class StandardHordeLdapService implements HordeLdapService +{ + /** + * Constructor + * + * @param Horde_Ldap $adapter LDAP adapter instance + */ + public function __construct( + private Horde_Ldap $adapter + ) { + } + + /** + * Get LDAP connection + * + * @return Horde_Ldap LDAP connection instance + */ + public function getAdapter(): Horde_Ldap + { + return $this->adapter; + } +} diff --git a/test/ActiveSyncTests.php b/test/ActiveSyncTests.php index 846e553a..983e495d 100644 --- a/test/ActiveSyncTests.php +++ b/test/ActiveSyncTests.php @@ -1,4 +1,5 @@ - * @category Horde - * @package Core - * @subpackage UnitTests - */ +/** +* Unit tests for ActiveSync functionality in Core. +* +* @author Michael J Rubinsky +* @category Horde +* @package Core +* @subpackage UnitTests +*/ class ActiveSyncTests extends TestCase { protected $_auth; @@ -52,7 +52,7 @@ public function _setupDeepStructure() 'd' => '.', 'label' => 'Drafts', 'level' => 0, - 'ob' =>$this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], + 'ob' => $this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], 'INBOX.Saved Emails' => [ 'a' => 8, @@ -115,7 +115,7 @@ public function _setupDeepStructure() 'd' => '.', 'label' => 'Sent', 'level' => 0, - 'ob' =>$this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], + 'ob' => $this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], 'INBOX.Spam' => [ 'a' => 8, @@ -148,7 +148,7 @@ public function _setupDeepStructure() 'user.benjamin.Saved Emails' => [ 'a' => 8, 'd' => '.', - 'label' =>'user.benjamin.Saved Emails', + 'label' => 'user.benjamin.Saved Emails', 'level' => 2, 'ob' => $this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], @@ -230,11 +230,11 @@ public function _setUpMailTest() 'INBOX' => [ 'a' => 40, 'd' => '/', - 'label' =>'Inbox', + 'label' => 'Inbox', 'level' => 0, 'ob' => $this->getMockSkipConstructor('Horde_Imap_Client_Mailbox'), ], 'sent-mail' => [ - 'a'=> 8, + 'a' => 8, 'd' => '/', 'label' => 'Sent', 'level' => 0, @@ -319,18 +319,18 @@ public function testGetFolderWithDeepFolderStructureAndPeriodDelimiter() $have[$f->_serverid] = true; switch ($f->_serverid) { - case 'INBOX.Drafts': - $this->assertEquals(3, $f->type); - break; - case 'INBOX': - $this->assertEquals(2, $f->type); - break; - case 'INBOX.Sent': - $this->assertEquals(5, $f->type); - break; - case 'INBOX.Spam': - $this->assertEquals(12, $f->type); - break; + case 'INBOX.Drafts': + $this->assertEquals(3, $f->type); + break; + case 'INBOX': + $this->assertEquals(2, $f->type); + break; + case 'INBOX.Sent': + $this->assertEquals(5, $f->type); + break; + case 'INBOX.Spam': + $this->assertEquals(12, $f->type); + break; } } @@ -362,15 +362,15 @@ public function testGetFoldersWhenEmailSupportDisabled() foreach ($folders as $f) { $have[$f->_serverid] = true; switch ($f->_serverid) { - case 'INBOX': - $this->assertEquals(2, $f->type); - break; - case 'Sent': - $this->assertEquals(5, $f->type); - break; - case 'Trash': - $this->assertEquals(4, $f->type); - break; + case 'INBOX': + $this->assertEquals(2, $f->type); + break; + case 'Sent': + $this->assertEquals(5, $f->type); + break; + case 'Trash': + $this->assertEquals(4, $f->type); + break; } } @@ -413,18 +413,18 @@ public function testGetFoldersWithForwardSlashDelimiter() $have[$f->_serverid] = true; switch ($f->_serverid) { - case 'Draft': - $this->assertEquals(3, $f->type); - break; - case 'INBOX': - $this->assertEquals(2, $f->type); - break; - case 'sent-mail': - $this->assertEquals(5, $f->type); - break; - case 'spam_folder': - $this->assertEquals(12, $f->type); - break; + case 'Draft': + $this->assertEquals(3, $f->type); + break; + case 'INBOX': + $this->assertEquals(2, $f->type); + break; + case 'sent-mail': + $this->assertEquals(5, $f->type); + break; + case 'spam_folder': + $this->assertEquals(12, $f->type); + break; } } diff --git a/test/Assets/ResponsiveAssetsTest.php b/test/Assets/ResponsiveAssetsTest.php index 15a9fd90..3121c2d2 100644 --- a/test/Assets/ResponsiveAssetsTest.php +++ b/test/Assets/ResponsiveAssetsTest.php @@ -38,7 +38,7 @@ public function testGetCssUrlsHordeOnly(): void { // Setup registry mock $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'themesfs' && $app === 'horde') { return '/horde/themes'; } @@ -53,7 +53,7 @@ public function testGetCssUrlsHordeOnly(): void // Setup filesystem mock - file exists $this->filesystemMock->method('fileExists') - ->willReturnCallback(function($path) { + ->willReturnCallback(function ($path) { return $path === '/horde/themes/default/responsive.css'; }); @@ -73,7 +73,7 @@ public function testGetCssUrlsWithAppCascade(): void { // Setup: Both horde and kronolith have responsive.css $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'themesfs') { return $app === 'horde' ? '/horde/themes' : '/kronolith/themes'; } @@ -88,7 +88,7 @@ public function testGetCssUrlsWithAppCascade(): void // Both files exist $this->filesystemMock->method('fileExists') - ->willReturnCallback(function($path) { + ->willReturnCallback(function ($path) { return str_contains($path, 'responsive.css'); }); @@ -124,7 +124,7 @@ public function testGetCssUrlsOnlyAppFileExists(): void { // Setup: Only app file exists, not horde base $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'themesfs') { return $app === 'horde' ? '/horde/themes' : '/kronolith/themes'; } @@ -139,7 +139,7 @@ public function testGetCssUrlsOnlyAppFileExists(): void // Only kronolith file exists $this->filesystemMock->method('fileExists') - ->willReturnCallback(function($path) { + ->willReturnCallback(function ($path) { return str_contains($path, '/kronolith/'); }); @@ -155,7 +155,7 @@ public function testGetJsUrlsHordeOnly(): void { // Setup registry mock $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'jsfs' && $app === 'horde') { return '/horde/js'; } @@ -170,7 +170,7 @@ public function testGetJsUrlsHordeOnly(): void // File exists $this->filesystemMock->method('fileExists') - ->willReturnCallback(function($path) { + ->willReturnCallback(function ($path) { return $path === '/horde/js/login-form.js'; }); @@ -187,7 +187,7 @@ public function testGetJsUrlsWithAppCascade(): void { // Setup: Both horde and kronolith have calendar.js $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'jsfs') { return $app === 'horde' ? '/horde/js' : '/kronolith/js'; } @@ -217,7 +217,7 @@ public function testGetJsUrlsMultipleFiles(): void { // Setup $this->registryMock->method('get') - ->willReturnCallback(function($key, $app) { + ->willReturnCallback(function ($key, $app) { if ($key === 'jsfs') { return '/horde/js'; } @@ -272,7 +272,7 @@ public function testGetThemeWithPreference(): void // Mock preferences $prefsMock = $this->createMock(\Horde_Prefs::class); $prefsMock->method('getValue') - ->willReturnCallback(function($key) { + ->willReturnCallback(function ($key) { return $key === 'theme' ? 'dark' : null; }); diff --git a/test/Authentication/Method/BasicTest.php b/test/Authentication/Method/BasicTest.php index 8b39548d..67558e8d 100644 --- a/test/Authentication/Method/BasicTest.php +++ b/test/Authentication/Method/BasicTest.php @@ -1,22 +1,23 @@ markTestSkipped('Instantiating BasicMethod below causes an error, because null is given when an array is needed. '); $request = new MockRequest([]); - $method = new BasicMethod; + $method = new BasicMethod(); $credential = $method->getCredentials($request); $this->expectException(NotFoundException::class); $credential->get('username'); @@ -25,33 +26,33 @@ function testNoHeader(): void } - function testFoundHeader(): void + public function testFoundHeader(): void { $this->markTestSkipped('Instantiating BasicMethod below causes an error, because null is given when an array is needed. '); $request = new MockRequest([ 'HEADER' => [ // admin:pass - 'authorization' => 'Basic YWRtaW46cGFzcw==' - ] + 'authorization' => 'Basic YWRtaW46cGFzcw==', + ], ]); - $method = new BasicMethod; + $method = new BasicMethod(); $credential = $method->getCredentials($request); $this->assertEquals('admin', $credential->get('username')); $this->assertEquals('pass', $credential->get('password')); } - function testFoundHeaderButNoBasic(): void + public function testFoundHeaderButNoBasic(): void { $this->markTestSkipped('Instantiating BasicMethod below causes an error, because null is given when an array is needed. '); $request = new MockRequest([ 'HEADER' => [ // admin:pass - 'authorization' => 'Token Rm9vYmFydG9rZW4K' - ] + 'authorization' => 'Token Rm9vYmFydG9rZW4K', + ], ]); - $method = new BasicMethod; + $method = new BasicMethod(); $credential = $method->getCredentials($request); $this->expectException(NotFoundException::class); $credential->get('username'); @@ -59,4 +60,4 @@ function testFoundHeaderButNoBasic(): void $credential->get('password'); } -} \ No newline at end of file +} diff --git a/test/Config/BackendConfigLoaderTest.php b/test/Config/BackendConfigLoaderTest.php index 1f5c98d8..1a72429a 100644 --- a/test/Config/BackendConfigLoaderTest.php +++ b/test/Config/BackendConfigLoaderTest.php @@ -29,8 +29,8 @@ protected function setUp(): void $this->vendorDir = $this->tempDir . '/vendor/horde'; $this->configDir = $this->tempDir . '/config'; - mkdir($this->vendorDir . '/passwd/config', 0755, true); - mkdir($this->configDir . '/passwd', 0755, true); + mkdir($this->vendorDir . '/passwd/config', 0o755, true); + mkdir($this->configDir . '/passwd', 0o755, true); $this->loader = new BackendConfigLoader($this->configDir, $this->vendorDir); } @@ -59,19 +59,21 @@ 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 + 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'); @@ -85,26 +87,30 @@ 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 + 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 + file_put_contents( + $localFile, + <<<'PHP' + false, + 'params' => ['table' => 'custom_table'], + ]; + PHP ); $state = $this->loader->load('passwd'); @@ -122,38 +128,44 @@ public function testSnippets(): void { // Vendor defaults $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; - file_put_contents($vendorFile, <<<'PHP' - true, - 'name' => 'Horde SQL', -]; -PHP + file_put_contents( + $vendorFile, + <<<'PHP' + true, + 'name' => 'Horde SQL', + ]; + PHP ); // Create snippets directory - mkdir($this->configDir . '/passwd/backends.d', 0755, true); + mkdir($this->configDir . '/passwd/backends.d', 0o755, true); // Snippet 1 $snippet1 = $this->configDir . '/passwd/backends.d/01-ldap.php'; - file_put_contents($snippet1, <<<'PHP' - false, - 'name' => 'LDAP Server', -]; -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 + file_put_contents( + $snippet2, + <<<'PHP' + false, + 'name' => 'Poppassd', + ]; + PHP ); $state = $this->loader->load('passwd'); @@ -167,12 +179,14 @@ public function testCaching(): void { // Vendor defaults $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; - file_put_contents($vendorFile, <<<'PHP' - 'Horde SQL', -]; -PHP + file_put_contents( + $vendorFile, + <<<'PHP' + 'Horde SQL', + ]; + PHP ); // First load @@ -188,12 +202,14 @@ public function testClearCache(): void { // Vendor defaults $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; - file_put_contents($vendorFile, <<<'PHP' - 'Horde SQL', -]; -PHP + file_put_contents( + $vendorFile, + <<<'PHP' + 'Horde SQL', + ]; + PHP ); // First load @@ -211,21 +227,25 @@ public function testClearCache(): void public function testLoadLayerVendor(): void { $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; - file_put_contents($vendorFile, <<<'PHP' - 'Vendor SQL', -]; -PHP + file_put_contents( + $vendorFile, + <<<'PHP' + 'Vendor SQL', + ]; + PHP ); $localFile = $this->configDir . '/passwd/backends.local.php'; - file_put_contents($localFile, <<<'PHP' - 'Local SQL', -]; -PHP + file_put_contents( + $localFile, + <<<'PHP' + 'Local SQL', + ]; + PHP ); $vendorLayer = $this->loader->loadLayer('passwd', 'vendor'); @@ -236,21 +256,25 @@ public function testLoadLayerVendor(): void public function testLoadLayerLocal(): void { $vendorFile = $this->vendorDir . '/passwd/config/backends.php'; - file_put_contents($vendorFile, <<<'PHP' - 'Vendor SQL', -]; -PHP + file_put_contents( + $vendorFile, + <<<'PHP' + 'Vendor SQL', + ]; + PHP ); $localFile = $this->configDir . '/passwd/backends.local.php'; - file_put_contents($localFile, <<<'PHP' - 'Local SQL', -]; -PHP + file_put_contents( + $localFile, + <<<'PHP' + 'Local SQL', + ]; + PHP ); $localLayer = $this->loader->loadLayer('passwd', 'local'); @@ -261,20 +285,22 @@ public function testLoadLayerLocal(): void 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 + 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'); @@ -296,24 +322,28 @@ 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 + file_put_contents( + $passwdFile, + <<<'PHP' + 'Passwd Backend', + ]; + PHP ); // Setup imp app - mkdir($this->vendorDir . '/imp/config', 0755, true); - mkdir($this->configDir . '/imp', 0755, true); + mkdir($this->vendorDir . '/imp/config', 0o755, true); + mkdir($this->configDir . '/imp', 0o755, true); $impFile = $this->vendorDir . '/imp/config/backends.php'; - file_put_contents($impFile, <<<'PHP' - 'IMP Backend', -]; -PHP + file_put_contents( + $impFile, + <<<'PHP' + 'IMP Backend', + ]; + PHP ); $passwdState = $this->loader->load('passwd'); diff --git a/test/Config/VhostTest.php b/test/Config/VhostTest.php index 375d1dac..57e8d777 100644 --- a/test/Config/VhostTest.php +++ b/test/Config/VhostTest.php @@ -130,7 +130,7 @@ public function testFromString(): void public function testDefaultConstructorValue(): void { // Test that default parameter works - $loader = function(Vhost|string $vhost = 'localhost') { + $loader = function (Vhost|string $vhost = 'localhost') { return Vhost::from($vhost); }; diff --git a/test/ConfigStateTest.php b/test/ConfigStateTest.php index 9db7d207..eda7f773 100644 --- a/test/ConfigStateTest.php +++ b/test/ConfigStateTest.php @@ -1,4 +1,5 @@ + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Factory; + +use Horde\Core\Factory\HordeLdapServiceFactory; +use Horde\Core\Service\StandardHordeLdapService; +use Horde\Core\Config\ConfigLoader; +use Horde\Core\Config\State; +use Horde_Cache; +use Horde_Injector; +use Horde_Ldap; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Tests for HordeLdapServiceFactory + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(HordeLdapServiceFactory::class)] +class HordeLdapServiceFactoryTest extends TestCase +{ + private ConfigLoader $configLoader; + private Horde_Injector $injector; + private HordeLdapServiceFactory $factory; + + protected function setUp(): void + { + $this->configLoader = $this->createMock(ConfigLoader::class); + $this->injector = $this->createMock(Horde_Injector::class); + $this->injector->method('getInstance') + ->willReturnCallback(function ($class) { + if ($class === ConfigLoader::class) { + return $this->configLoader; + } + throw new \Exception("Unknown dependency: $class"); + }); + + $this->factory = new HordeLdapServiceFactory(); + } + + public function testCreateDefaultService(): void + { + $config = [ + 'hostspec' => 'ldap.example.com', + 'port' => 389, + 'basedn' => 'dc=example,dc=com', + ]; + + $mockState = $this->createMock(State::class); + $mockState->method('has')->willReturn(true); + $mockState->method('get')->with('ldap')->willReturn($config); + + $this->configLoader->method('load')->with('horde')->willReturn($mockState); + + $service = $this->factory->create($this->injector, 'horde'); + + $this->assertInstanceOf(StandardHordeLdapService::class, $service); + $this->assertInstanceOf(Horde_Ldap::class, $service->getAdapter()); + } + + public function testCreateServiceSpecific(): void + { + $defaultConfig = [ + 'hostspec' => 'ldap.example.com', + 'basedn' => 'dc=example,dc=com', + ]; + + $groupsConfig = [ + 'hostspec' => 'ldap-groups.example.com', + 'basedn' => 'ou=groups,dc=example,dc=com', + ]; + + $mockState = $this->createMock(State::class); + $mockState->method('has')->willReturnCallback(function ($key) { + return in_array($key, ['ldap', 'ldap.service.groups']); + }); + $mockState->method('get')->willReturnCallback(function ($key) use ($defaultConfig, $groupsConfig) { + return $key === 'ldap.service.groups' ? $groupsConfig : $defaultConfig; + }); + + $this->configLoader->method('load')->with('horde')->willReturn($mockState); + + $service = $this->factory->create($this->injector, 'horde:groups'); + + $this->assertInstanceOf(StandardHordeLdapService::class, $service); + $this->assertInstanceOf(Horde_Ldap::class, $service->getAdapter()); + } + + public function testConnectionPooling(): void + { + $config = [ + 'hostspec' => 'ldap.example.com', + 'basedn' => 'dc=example,dc=com', + ]; + + $mockState = $this->createMock(State::class); + $mockState->method('has')->willReturn(true); + $mockState->method('get')->with('ldap')->willReturn($config); + + $this->configLoader->method('load')->with('horde')->willReturn($mockState); + + $service1 = $this->factory->create($this->injector, 'horde'); + $service2 = $this->factory->create($this->injector, 'horde'); + + $this->assertSame( + $service1->getAdapter(), + $service2->getAdapter(), + 'Same config should return same adapter instance' + ); + } + + public function testDifferentConfigsDifferentAdapters(): void + { + $config1 = [ + 'hostspec' => 'ldap1.example.com', + 'basedn' => 'dc=example,dc=com', + ]; + + $config2 = [ + 'hostspec' => 'ldap2.example.com', + 'basedn' => 'dc=mail,dc=example,dc=com', + ]; + + $mockState = $this->createMock(State::class); + $mockState->method('has')->willReturnCallback(function ($key) { + return in_array($key, ['ldap', 'ldap.service.groups']); + }); + $mockState->method('get')->willReturnCallback(function ($key) use ($config1, $config2) { + return $key === 'ldap.service.groups' ? $config2 : $config1; + }); + + $this->configLoader->method('load')->with('horde')->willReturn($mockState); + + $service1 = $this->factory->create($this->injector, 'horde'); + $service2 = $this->factory->create($this->injector, 'horde:groups'); + + $this->assertNotSame( + $service1->getAdapter(), + $service2->getAdapter(), + 'Different configs should get different adapters' + ); + } + + public function testMissingConfigThrows(): void + { + $mockState = $this->createMock(State::class); + $mockState->method('has')->willReturn(false); + + $this->configLoader->method('load')->with('horde')->willReturn($mockState); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No LDAP configuration found'); + + $this->factory->create($this->injector, 'horde'); + } + + public function testParseServiceIdSimple(): void + { + $reflection = new \ReflectionClass($this->factory); + $method = $reflection->getMethod('parseServiceId'); + $method->setAccessible(true); + + $result = $method->invoke($this->factory, 'horde'); + $this->assertEquals(['horde', null], $result); + } + + public function testParseServiceIdWithService(): void + { + $reflection = new \ReflectionClass($this->factory); + $method = $reflection->getMethod('parseServiceId'); + $method->setAccessible(true); + + $result = $method->invoke($this->factory, 'horde:groups'); + $this->assertEquals(['horde', 'groups'], $result); + + $result = $method->invoke($this->factory, 'imp:storage'); + $this->assertEquals(['imp', 'storage'], $result); + } +} diff --git a/test/Factory/KolabServerTest.php b/test/Factory/KolabServerTest.php index 2de0b4b5..3f4a7d62 100644 --- a/test/Factory/KolabServerTest.php +++ b/test/Factory/KolabServerTest.php @@ -1,4 +1,5 @@ connect('test-route', '/test', ['controller' => 'TestController']); -PHP + file_put_contents( + $routesFile, + <<<'PHP' + connect('test-route', '/test', ['controller' => 'TestController']); + PHP ); // Setup registry mock @@ -185,10 +188,12 @@ public function testRouteWithDefaultMiddlewareStack(): void $routesFile = $tempDir . '/config/routes.php'; // Route without explicit stack = default stack - file_put_contents($routesFile, <<<'PHP' -connect('default-stack', '/default', ['controller' => 'DefaultController']); -PHP + file_put_contents( + $routesFile, + <<<'PHP' + connect('default-stack', '/default', ['controller' => 'DefaultController']); + PHP ); $this->registry->method('get') @@ -260,13 +265,15 @@ public function testRouteWithEmptyStackBypassesDefaultMiddleware(): void $routesFile = $tempDir . '/config/routes.php'; // Explicit empty stack = NO middleware - file_put_contents($routesFile, <<<'PHP' -connect('no-middleware', '/public', [ - 'controller' => 'PublicController', - 'stack' => [] -]); -PHP + file_put_contents( + $routesFile, + <<<'PHP' + connect('no-middleware', '/public', [ + 'controller' => 'PublicController', + 'stack' => [] + ]); + PHP ); $this->registry->method('get') @@ -334,13 +341,15 @@ public function testRouteWithHordeAuthTypeNoneBypassesMiddleware(): void $routesFile = $tempDir . '/config/routes.php'; // HordeAuthType=NONE implies empty stack - file_put_contents($routesFile, <<<'PHP' -connect('auth-none', '/login', [ - 'controller' => 'LoginController', - 'HordeAuthType' => 'NONE' -]); -PHP + file_put_contents( + $routesFile, + <<<'PHP' + connect('auth-none', '/login', [ + 'controller' => 'LoginController', + 'HordeAuthType' => 'NONE' + ]); + PHP ); $this->registry->method('get') @@ -446,13 +455,15 @@ public function testRouteMatchAddedAsAttribute(): void mkdir($tempDir . '/config'); $routesFile = $tempDir . '/config/routes.php'; - file_put_contents($routesFile, <<<'PHP' -connect('test-params', '/item/:id', [ - 'controller' => 'ItemController', - 'stack' => [] -]); -PHP + file_put_contents( + $routesFile, + <<<'PHP' + connect('test-params', '/item/:id', [ + 'controller' => 'ItemController', + 'stack' => [] + ]); + PHP ); $this->registry->method('get') diff --git a/test/Middleware/AuthHordeSessionTest.php b/test/Middleware/AuthHordeSessionTest.php index 8049e114..db80a573 100644 --- a/test/Middleware/AuthHordeSessionTest.php +++ b/test/Middleware/AuthHordeSessionTest.php @@ -1,4 +1,5 @@ registry); + } - protected function getMiddleware() - { - return new AuthIsGlobalAdmin($this->registry); - } - - public function testIsAdmin() - { + public function testIsAdmin() + { $username = 'testuser01'; $middleware = $this->getMiddleware(); $this->registry->method('isAuthenticated')->willReturn(true); @@ -44,10 +43,10 @@ public function testIsAdmin() $this->assertTrue($authAdminUser); // tests if $authAdminUser is set to true -> Admin $this->assertEquals(200, $response->getStatusCode()); - } + } - public function testIsNotAdmin() - { + public function testIsNotAdmin() + { $username = 'testuser01'; $middleware = $this->getMiddleware(); $this->registry->method('isAuthenticated')->willReturn(true); @@ -59,24 +58,24 @@ public function testIsNotAdmin() $authAdminUser = $this->recentlyHandledRequest->getAttribute('HORDE_GLOBAL_ADMIN'); // assert that $authAdminUser has the correct Value - $this->assertNull($authAdminUser); // asserTrue/False before, resulted in failing to assert that null is false + $this->assertNull($authAdminUser); // asserTrue/False before, resulted in failing to assert that null is false $this->assertEquals(200, $response->getStatusCode()); - } + } - public function testUserIsNotAuthenticated() - { - $username = 'testuser01'; - $middleware = $this->getMiddleware(); - $this->registry->method('isAuthenticated')->willReturn(false); - $this->registry->method('getAuth')->willReturn($username); - $this->registry->method('isAdmin')->willReturn(true); - $request = $this->requestFactory->createServerRequest('GET', '/test'); - $response = $middleware->process($request, $this->handler); + public function testUserIsNotAuthenticated() + { + $username = 'testuser01'; + $middleware = $this->getMiddleware(); + $this->registry->method('isAuthenticated')->willReturn(false); + $this->registry->method('getAuth')->willReturn($username); + $this->registry->method('isAdmin')->willReturn(true); + $request = $this->requestFactory->createServerRequest('GET', '/test'); + $response = $middleware->process($request, $this->handler); - $authAdminUser = $this->recentlyHandledRequest->getAttribute('HORDE_GLOBAL_ADMIN'); - // assert that $authAdminUser has the correct Value + $authAdminUser = $this->recentlyHandledRequest->getAttribute('HORDE_GLOBAL_ADMIN'); + // assert that $authAdminUser has the correct Value - $this->assertNull($authAdminUser); - $this->assertEquals(200, $response->getStatusCode()); - } - } \ No newline at end of file + $this->assertNull($authAdminUser); + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/test/Middleware/DemandAuthHeaderTest.php b/test/Middleware/DemandAuthHeaderTest.php index 49ebaa9c..23836046 100644 --- a/test/Middleware/DemandAuthHeaderTest.php +++ b/test/Middleware/DemandAuthHeaderTest.php @@ -1,4 +1,5 @@ _name; + case 'value': + return $this->_name; } } } diff --git a/test/NlsconfigTest.php b/test/NlsconfigTest.php index 97510c4a..abc5dcc9 100644 --- a/test/NlsconfigTest.php +++ b/test/NlsconfigTest.php @@ -1,4 +1,5 @@ tempAppDir . '/config/routes.php', <<<'PHP' -connect('protected-api', '/api/protected', [ - 'controller' => 'ProtectedApiController' -]); -PHP + file_put_contents( + $this->tempAppDir . '/config/routes.php', + <<<'PHP' + connect('protected-api', '/api/protected', [ + 'controller' => 'ProtectedApiController' + ]); + PHP ); // Mock registry @@ -142,7 +145,7 @@ public function testFullRequestWithDefaultMiddleware(): void $body = $this->streamFactory->createStream(json_encode([ 'status' => 'success', 'data' => 'Protected data', - 'middlewares' => $middlewaresExecuted + 'middlewares' => $middlewaresExecuted, ])); return $this->responseFactory->createResponse(200) @@ -211,14 +214,16 @@ public function testFullRequestWithDefaultMiddleware(): void public function testFullRequestBypassingDefaultMiddleware(): void { // Create routes file with empty stack - file_put_contents($this->tempAppDir . '/config/routes.php', <<<'PHP' -connect('public-api', '/api/public', [ - 'controller' => 'PublicApiController', - 'stack' => [] // Explicitly bypass default middleware -]); -PHP + file_put_contents( + $this->tempAppDir . '/config/routes.php', + <<<'PHP' + connect('public-api', '/api/public', [ + 'controller' => 'PublicApiController', + 'stack' => [] // Explicitly bypass default middleware + ]); + PHP ); // Mock registry @@ -253,7 +258,7 @@ public function testFullRequestBypassingDefaultMiddleware(): void $body = $this->streamFactory->createStream(json_encode([ 'status' => 'success', 'data' => 'Public data - no auth required', - 'middlewares' => $middlewaresExecuted + 'middlewares' => $middlewaresExecuted, ])); return $this->responseFactory->createResponse(200) @@ -314,7 +319,7 @@ public function testFullRequestBypassingDefaultMiddleware(): void */ private function createTrackingMiddleware(string $name, array &$tracker): object { - return new class($name, $tracker) implements \Psr\Http\Server\MiddlewareInterface { + return new class ($name, $tracker) implements \Psr\Http\Server\MiddlewareInterface { public function __construct( private string $name, private array &$tracker diff --git a/test/RegistryTest.php b/test/RegistryTest.php index 0a3974ad..6ae2fc89 100644 --- a/test/RegistryTest.php +++ b/test/RegistryTest.php @@ -1,4 +1,5 @@ _tmpdir = sys_get_temp_dir() . '/' . uniqid() . '/horde'; - mkdir($this->_tmpdir, 0777, true); + mkdir($this->_tmpdir, 0o777, true); $config = new RegistryconfigStub(); $_SERVER['SCRIPT_URL'] = '/horde/foo/bar'; diff --git a/test/Service/FileGroupServiceTest.php b/test/Service/FileGroupServiceTest.php new file mode 100644 index 00000000..1f9b5c67 --- /dev/null +++ b/test/Service/FileGroupServiceTest.php @@ -0,0 +1,313 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\FileGroupService; +use Horde\Core\Service\Exception\GroupNotFoundException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use RuntimeException; + +/** + * Tests for FileGroupService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(FileGroupService::class)] +class FileGroupServiceTest extends TestCase +{ + private string $tempFile; + + protected function setUp(): void + { + $this->tempFile = tempnam(sys_get_temp_dir(), 'group_test_'); + } + + protected function tearDown(): void + { + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + } + + private function createFixtureFile(string $content): void + { + file_put_contents($this->tempFile, $content); + } + + public function testParseBasicGroupFile(): void + { + $this->createFixtureFile( + <<tempFile, useGid: false); + + $this->assertTrue($service->exists('root')); + $this->assertTrue($service->exists('daemon')); + $this->assertTrue($service->exists('bin')); + $this->assertTrue($service->exists('sys')); + } + + public function testParseGroupWithMembers(): void + { + $this->createFixtureFile( + <<tempFile); + + $group = $service->get('developers'); + $this->assertEquals('developers', $group->name); + $this->assertEquals(['alice', 'bob', 'charlie'], $group->members); + } + + public function testParseGroupWithNoMembers(): void + { + $this->createFixtureFile( + <<tempFile); + + $group = $service->get('empty'); + $this->assertEmpty($group->members); + } + + public function testUseGidAsId(): void + { + $this->createFixtureFile( + <<tempFile, useGid: true); + + // Access by GID + $group = $service->get('0'); + $this->assertEquals('root', $group->name); + + $group = $service->get('1001'); + $this->assertEquals('developers', $group->name); + } + + public function testUseNameAsIdDefault(): void + { + $this->createFixtureFile( + <<tempFile, useGid: false); + + $group = $service->get('developers'); + $this->assertEquals('developers', $group->id); + $this->assertEquals('developers', $group->name); + } + + public function testSkipComments(): void + { + $this->createFixtureFile( + <<tempFile); + + $result = $service->listAll(); + $this->assertCount(2, $result->groups); + } + + public function testSkipMalformedLines(): void + { + $this->createFixtureFile( + <<tempFile); + + $result = $service->listAll(); + $this->assertCount(3, $result->groups); // Only valid lines + } + + public function testListAllPagination(): void + { + $lines = []; + for ($i = 1; $i <= 25; $i++) { + $lines[] = "group$i:x:$i:"; + } + $this->createFixtureFile(implode("\n", $lines)); + + $service = new FileGroupService($this->tempFile); + + // Page 1 + $result = $service->listAll(1, 10); + $this->assertCount(10, $result->groups); + $this->assertEquals(25, $result->total); + $this->assertTrue($result->hasNext); + $this->assertFalse($result->hasPrev); + + // Page 3 (last page, partial) + $result = $service->listAll(3, 10); + $this->assertCount(5, $result->groups); + $this->assertFalse($result->hasNext); + $this->assertTrue($result->hasPrev); + } + + public function testGetGroupByName(): void + { + $this->createFixtureFile( + <<tempFile); + + $group = $service->get('users'); + $this->assertEquals('users', $group->name); + $this->assertEquals(['alice', 'bob'], $group->members); + } + + public function testGetNonExistentGroupThrows(): void + { + $this->createFixtureFile("root:x:0:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->expectException(GroupNotFoundException::class); + $service->get('nonexistent'); + } + + public function testExistsByName(): void + { + $this->createFixtureFile("developers:x:1001:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->assertTrue($service->exists('developers')); + $this->assertFalse($service->exists('nonexistent')); + } + + public function testGetMembers(): void + { + $this->createFixtureFile("team:x:1001:alice,bob,charlie\n"); + + $service = new FileGroupService($this->tempFile); + + $members = $service->getMembers('team'); + $this->assertEquals(['alice', 'bob', 'charlie'], $members); + } + + public function testIsReadOnly(): void + { + $this->createFixtureFile("root:x:0:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->assertTrue($service->isReadOnly()); + } + + public function testCreateThrowsReadOnly(): void + { + $this->createFixtureFile("root:x:0:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('read-only'); + $service->create('newgroup'); + } + + public function testDeleteThrowsReadOnly(): void + { + $this->createFixtureFile("root:x:0:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('read-only'); + $service->delete('root'); + } + + public function testAddMemberThrowsReadOnly(): void + { + $this->createFixtureFile("root:x:0:\n"); + + $service = new FileGroupService($this->tempFile); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('read-only'); + $service->addMember('root', 'alice'); + } + + public function testFileNotFoundThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('not found'); + new FileGroupService('/nonexistent/file'); + } + + public function testEmptyFileReturnsEmptyList(): void + { + $this->createFixtureFile(''); + + $service = new FileGroupService($this->tempFile); + + $result = $service->listAll(); + $this->assertEmpty($result->groups); + $this->assertEquals(0, $result->total); + } + + public function testTrailingCommaInMemberList(): void + { + $this->createFixtureFile("team:x:1001:alice,bob,\n"); + + $service = new FileGroupService($this->tempFile); + + $members = $service->getMembers('team'); + // Empty strings should be filtered out + $this->assertCount(2, $members); + $this->assertEquals(['alice', 'bob'], $members); + } +} diff --git a/test/Service/FilePrefsServiceTest.php b/test/Service/FilePrefsServiceTest.php new file mode 100644 index 00000000..6c042062 --- /dev/null +++ b/test/Service/FilePrefsServiceTest.php @@ -0,0 +1,275 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\FilePrefsService; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use RuntimeException; + +/** + * Tests for FilePrefsService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(FilePrefsService::class)] +class FilePrefsServiceTest extends TestCase +{ + private string $tempDir; + private FilePrefsService $service; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/prefs_test_' . uniqid(); + mkdir($this->tempDir, 0o700, true); + $this->service = new FilePrefsService($this->tempDir); + } + + protected function tearDown(): void + { + // Recursively delete temp directory + $this->recursiveDelete($this->tempDir); + } + + private function recursiveDelete(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->recursiveDelete($path) : unlink($path); + } + rmdir($dir); + } + + public function testSetAndGetValue(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $value = $this->service->getValue('user1', 'horde', 'theme'); + + $this->assertEquals('silver', $value); + } + + public function testGetNonExistentValue(): void + { + $value = $this->service->getValue('user1', 'horde', 'missing'); + + $this->assertNull($value); + } + + public function testGetValueFromNonExistentFile(): void + { + $value = $this->service->getValue('nonexistent', 'horde', 'theme'); + + $this->assertNull($value); + } + + public function testSetMultipleValuesInScope(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->service->setValue('user1', 'horde', 'language', 'de_DE'); + $this->service->setValue('user1', 'horde', 'timezone', 'Europe/Berlin'); + + $this->assertEquals('silver', $this->service->getValue('user1', 'horde', 'theme')); + $this->assertEquals('de_DE', $this->service->getValue('user1', 'horde', 'language')); + $this->assertEquals('Europe/Berlin', $this->service->getValue('user1', 'horde', 'timezone')); + } + + public function testScopeSeparation(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->service->setValue('user1', 'imp', 'theme', 'gold'); + + $this->assertEquals('silver', $this->service->getValue('user1', 'horde', 'theme')); + $this->assertEquals('gold', $this->service->getValue('user1', 'imp', 'theme')); + } + + public function testUserSeparation(): void + { + $this->service->setValue('alice', 'horde', 'theme', 'silver'); + $this->service->setValue('bob', 'horde', 'theme', 'gold'); + + $this->assertEquals('silver', $this->service->getValue('alice', 'horde', 'theme')); + $this->assertEquals('gold', $this->service->getValue('bob', 'horde', 'theme')); + } + + public function testDeleteValue(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->assertTrue($this->service->exists('user1', 'horde', 'theme')); + + $this->service->deleteValue('user1', 'horde', 'theme'); + + $this->assertFalse($this->service->exists('user1', 'horde', 'theme')); + $this->assertNull($this->service->getValue('user1', 'horde', 'theme')); + } + + public function testDeleteNonExistentValueDoesNotThrow(): void + { + // Should not throw even if value doesn't exist + $this->service->deleteValue('user1', 'horde', 'nonexistent'); + + $this->assertFalse($this->service->exists('user1', 'horde', 'nonexistent')); + } + + public function testGetAllInScope(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->service->setValue('user1', 'horde', 'language', 'de_DE'); + $this->service->setValue('user1', 'horde', 'timezone', 'Europe/Berlin'); + + $all = $this->service->getAllInScope('user1', 'horde'); + + $this->assertCount(3, $all); + $this->assertEquals('silver', $all['theme']); + $this->assertEquals('de_DE', $all['language']); + $this->assertEquals('Europe/Berlin', $all['timezone']); + } + + public function testGetAllInScopeEmptyForNonExistent(): void + { + $all = $this->service->getAllInScope('nonexistent', 'horde'); + + $this->assertIsArray($all); + $this->assertEmpty($all); + } + + public function testExists(): void + { + $this->assertFalse($this->service->exists('user1', 'horde', 'theme')); + + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + + $this->assertTrue($this->service->exists('user1', 'horde', 'theme')); + } + + public function testUpdateExistingValue(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->assertEquals('silver', $this->service->getValue('user1', 'horde', 'theme')); + + $this->service->setValue('user1', 'horde', 'theme', 'gold'); + $this->assertEquals('gold', $this->service->getValue('user1', 'horde', 'theme')); + } + + public function testSetComplexValue(): void + { + $complex = ['key1' => 'value1', 'key2' => ['nested' => 'data']]; + $this->service->setValue('user1', 'horde', 'complex', $complex); + + $retrieved = $this->service->getValue('user1', 'horde', 'complex'); + $this->assertEquals($complex, $retrieved); + } + + public function testSetNullValue(): void + { + $this->service->setValue('user1', 'horde', 'nullable', null); + + $value = $this->service->getValue('user1', 'horde', 'nullable'); + $this->assertNull($value); + } + + public function testSetBooleanValue(): void + { + $this->service->setValue('user1', 'horde', 'enabled', true); + $this->assertTrue($this->service->getValue('user1', 'horde', 'enabled')); + + $this->service->setValue('user1', 'horde', 'disabled', false); + $this->assertFalse($this->service->getValue('user1', 'horde', 'disabled')); + } + + public function testFilePermissions(): void + { + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + + $file = $this->tempDir . '/horde/user1.prefs'; + $this->assertFileExists($file); + + $perms = fileperms($file) & 0o777; + $this->assertEquals(0o600, $perms); // Owner read/write only + } + + public function testDirectoryStructureCreation(): void + { + $this->service->setValue('user1', 'imp', 'theme', 'silver'); + + $scopeDir = $this->tempDir . '/imp'; + $this->assertDirectoryExists($scopeDir); + + $file = $scopeDir . '/user1.prefs'; + $this->assertFileExists($file); + } + + public function testSanitizeUsernamePreventsDotDotAttack(): void + { + // Attempt directory traversal attack + $this->service->setValue('../../../etc/passwd', 'horde', 'theme', 'hack'); + + // Should create file in tempDir with sanitized name + $file = $this->tempDir . '/horde/passwd.prefs'; // basename strips ../ + $this->assertFileExists($file); + + // Verify file was created in tempDir (not traversed out) + $realPath = realpath($file); + $this->assertStringStartsWith(realpath($this->tempDir), $realPath); + + // Verify we can read the value back (proving it was stored safely) + $value = $this->service->getValue('../../../etc/passwd', 'horde', 'theme'); + $this->assertEquals('hack', $value); + } + + public function testConstructorCreatesDirectory(): void + { + $newDir = sys_get_temp_dir() . '/prefs_test_new_' . uniqid(); + + $this->assertDirectoryDoesNotExist($newDir); + + $service = new FilePrefsService($newDir); + + $this->assertDirectoryExists($newDir); + + // Cleanup + rmdir($newDir); + } + + public function testHandleCorruptedFile(): void + { + // Manually create a corrupted prefs file + $scopeDir = $this->tempDir . '/horde'; + mkdir($scopeDir, 0o700, true); + file_put_contents($scopeDir . '/user1.prefs', 'corrupted data {not serialized}'); + + // Should return empty array instead of throwing + $all = $this->service->getAllInScope('user1', 'horde'); + $this->assertIsArray($all); + $this->assertEmpty($all); + + // Should return null for specific key + $value = $this->service->getValue('user1', 'horde', 'theme'); + $this->assertNull($value); + + // Should be able to overwrite corrupted file + $this->service->setValue('user1', 'horde', 'theme', 'silver'); + $this->assertEquals('silver', $this->service->getValue('user1', 'horde', 'theme')); + } +} diff --git a/test/Service/LdapGroupServiceTest.php b/test/Service/LdapGroupServiceTest.php new file mode 100644 index 00000000..b6ad69ae --- /dev/null +++ b/test/Service/LdapGroupServiceTest.php @@ -0,0 +1,264 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\LdapGroupService; +use Horde\Core\Service\HordeLdapService; +use Horde\Core\Service\GroupInfo; +use Horde_Ldap; +use Horde_Ldap_Search; +use Horde_Ldap_Entry; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Tests for LdapGroupService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(LdapGroupService::class)] +class LdapGroupServiceTest extends TestCase +{ + private HordeLdapService $ldapService; + private Horde_Ldap $ldapAdapter; + private LdapGroupService $service; + + protected function setUp(): void + { + $this->ldapService = $this->createMock(HordeLdapService::class); + $this->ldapAdapter = $this->createMock(Horde_Ldap::class); + + $this->ldapService->method('getAdapter')->willReturn($this->ldapAdapter); + + $this->service = new LdapGroupService( + $this->ldapService, + 'ou=groups,dc=example,dc=com' + ); + } + + public function testListAllGroups(): void + { + $search = $this->createMock(Horde_Ldap_Search::class); + + $entry1 = $this->createMock(Horde_Ldap_Entry::class); + $entry1->method('getValue')->willReturnMap([ + ['cn', 'single', 'developers'], + ['memberUid', null, ['alice', 'bob']], + ['mail', 'single', 'dev@example.com'], + ]); + + $entry2 = $this->createMock(Horde_Ldap_Entry::class); + $entry2->method('getValue')->willReturnMap([ + ['cn', 'single', 'admins'], + ['memberUid', null, ['charlie']], + ['mail', 'single', null], + ]); + + $search->expects($this->exactly(2))->method('valid')->willReturn(true, false); + $search->expects($this->exactly(2))->method('current')->willReturn($entry1, $entry2); + $search->expects($this->exactly(2))->method('next'); + + $this->ldapAdapter->expects($this->once()) + ->method('search') + ->willReturn($search); + + $result = $this->service->listAll(); + + $this->assertCount(2, $result->getGroups()); + $this->assertEquals('developers', $result->getGroups()[0]->name); + $this->assertEquals(['alice', 'bob'], $result->getGroups()[0]->members); + } + + public function testGetGroup(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->will( + $this->returnValueMap([ + ['memberUid', null, ['alice', 'bob']], + ['mail', 'single', 'dev@example.com'], + ]) + ); + + $this->ldapAdapter->expects($this->once()) + ->method('getEntry') + ->with('cn=developers,ou=groups,dc=example,dc=com') + ->willReturn($entry); + + $group = $this->service->get('developers'); + + $this->assertEquals('developers', $group->id); + $this->assertEquals(['alice', 'bob'], $group->members); + $this->assertEquals('dev@example.com', $group->extra['email'] ?? ''); + } + + public function testCreateGroup(): void + { + $this->ldapAdapter->expects($this->once()) + ->method('add') + ->with($this->callback(function ($entry) { + return $entry instanceof Horde_Ldap_Entry; + })); + + $result = $this->service->create('developers'); + + $this->assertEquals('developers', $result->id); + } + + public function testUpdateMembers(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->expects($this->once()) + ->method('replace') + ->with(['memberUid' => ['alice', 'bob', 'charlie']]); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->expects($this->once()) + ->method('getEntry') + ->willReturn($entry); + + $this->service->setMembers('developers', ['alice', 'bob', 'charlie']); + } + + public function testDeleteGroup(): void + { + $this->ldapAdapter->expects($this->once()) + ->method('delete') + ->with('cn=developers,ou=groups,dc=example,dc=com'); + + $this->service->delete('developers'); + } + + public function testExistsTrue(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->will( + $this->returnValueMap([ + ['memberUid', null, []], + ['mail', 'single', null], + ]) + ); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->assertTrue($this->service->exists('developers')); + } + + public function testExistsFalse(): void + { + $this->ldapAdapter->method('getEntry') + ->willThrowException(new \Horde_Ldap_Exception('Not found')); + + $this->assertFalse($this->service->exists('nonexistent')); + } + + public function testAddMember(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['alice']); + $entry->expects($this->once()) + ->method('replace') + ->with(['memberUid' => ['alice', 'bob']]); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->addMember('developers', 'bob'); + } + + public function testAddMemberAlreadyExists(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['alice', 'bob']); + $entry->expects($this->never())->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->addMember('developers', 'bob'); + } + + public function testRemoveMember(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['alice', 'bob']); + $entry->expects($this->once()) + ->method('replace') + ->with(['memberUid' => ['alice']]); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->removeMember('developers', 'bob'); + } + + public function testGetMembers(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->will( + $this->returnValueMap([ + ['memberUid', null, ['alice', 'bob']], + ['mail', 'single', null], + ]) + ); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $members = $this->service->getMembers('developers'); + + $this->assertEquals(['alice', 'bob'], $members); + } + + public function testAddMembers(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['alice']); + $entry->expects($this->once()) + ->method('replace') + ->with(['memberUid' => ['alice', 'bob', 'charlie']]); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->addMembers('developers', ['bob', 'charlie']); + } + + public function testRemoveMembers(): void + { + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['alice', 'bob', 'charlie']); + $entry->expects($this->once()) + ->method('replace') + ->with(['memberUid' => ['alice']]); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->removeMembers('developers', ['bob', 'charlie']); + } + + public function testIsReadOnly(): void + { + $this->assertFalse($this->service->isReadOnly()); + } +} diff --git a/test/Service/LdapPrefsServiceTest.php b/test/Service/LdapPrefsServiceTest.php new file mode 100644 index 00000000..06300bf9 --- /dev/null +++ b/test/Service/LdapPrefsServiceTest.php @@ -0,0 +1,285 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\LdapPrefsService; +use Horde\Core\Service\HordeLdapService; +use Horde_Ldap; +use Horde_Ldap_Search; +use Horde_Ldap_Entry; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Tests for LdapPrefsService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(LdapPrefsService::class)] +class LdapPrefsServiceTest extends TestCase +{ + private HordeLdapService $ldapService; + private Horde_Ldap $ldapAdapter; + private LdapPrefsService $service; + + protected function setUp(): void + { + $this->ldapService = $this->createMock(HordeLdapService::class); + $this->ldapAdapter = $this->createMock(Horde_Ldap::class); + + $this->ldapService->method('getAdapter')->willReturn($this->ldapAdapter); + + $this->service = new LdapPrefsService( + $this->ldapService, + 'ou=users,dc=example,dc=com' + ); + } + + public function testGetValueFromHordePerson(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue') + ->with('hordePrefHordeTheme', 'single') + ->willReturn('silver'); + + $search->method('shiftEntry')->willReturn($entry); + + $this->ldapAdapter->method('search')->willReturn($search); + + $value = $this->service->getValue('alice', 'horde', 'theme'); + + $this->assertEquals('silver', $value); + } + + public function testGetValueFromUserEntry(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(0); + + $this->ldapAdapter->method('search')->willReturn($search); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue') + ->with('hordePrefHordeTheme', 'single') + ->willReturn('blue'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $value = $this->service->getValue('alice', 'horde', 'theme'); + + $this->assertEquals('blue', $value); + } + + public function testGetValueNotFound(): void + { + $this->ldapAdapter->method('findUserDN') + ->willThrowException(new \Horde_Ldap_Exception('User not found')); + + $value = $this->service->getValue('nonexistent', 'horde', 'theme'); + + $this->assertNull($value); + } + + public function testSetValueNewHordePerson(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(0); + + $this->ldapAdapter->method('search')->willReturn($search); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue') + ->with('objectClass') + ->willReturn(['inetOrgPerson']); + + $entry->expects($this->exactly(2)) + ->method('replace'); + $entry->expects($this->once()) + ->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->setValue('alice', 'horde', 'theme', 'silver'); + } + + public function testSetValueExistingHordePerson(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->expects($this->once()) + ->method('replace') + ->with(['hordePrefHordeTheme' => 'silver']); + $entry->expects($this->once()) + ->method('update'); + + $search->method('shiftEntry')->willReturn($entry); + + $this->ldapAdapter->method('search')->willReturn($search); + + $this->service->setValue('alice', 'horde', 'theme', 'silver'); + } + + public function testDeleteValue(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->expects($this->once()) + ->method('delete') + ->with(['hordePrefHordeTheme' => []]); + $entry->expects($this->once()) + ->method('update'); + + $search->method('shiftEntry')->willReturn($entry); + + $this->ldapAdapter->method('search')->willReturn($search); + + $this->service->deleteValue('alice', 'horde', 'theme'); + } + + public function testDeleteValueUserNotFound(): void + { + $this->ldapAdapter->method('findUserDN') + ->willThrowException(new \Horde_Ldap_Exception('User not found')); + + // Should not throw exception + $this->service->deleteValue('nonexistent', 'horde', 'theme'); + $this->assertTrue(true); + } + + public function testGetAllInScope(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValues')->willReturn([ + 'cn' => ['Alice'], + 'hordePrefHordeTheme' => ['silver'], + 'hordePrefHordeLanguage' => ['en_US'], + 'hordePrefImpLayout' => ['wide'], + ]); + + $search->method('shiftEntry')->willReturn($entry); + + $this->ldapAdapter->method('search')->willReturn($search); + + $prefs = $this->service->getAllInScope('alice', 'horde'); + + $this->assertArrayHasKey('theme', $prefs); + $this->assertEquals('silver', $prefs['theme']); + $this->assertArrayHasKey('language', $prefs); + $this->assertEquals('en_US', $prefs['language']); + $this->assertArrayNotHasKey('layout', $prefs); // Different scope (imp) + } + + public function testGetAllInScopeEmpty(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValues')->willReturn([ + 'cn' => ['Alice'], + ]); + + $search->method('shiftEntry')->willReturn($entry); + + $this->ldapAdapter->method('search')->willReturn($search); + + $prefs = $this->service->getAllInScope('alice', 'horde'); + + $this->assertEmpty($prefs); + } + + public function testExistsTrue(): void + { + $this->ldapAdapter->method('findUserDN') + ->with('alice') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $this->assertTrue($this->service->exists('alice')); + } + + public function testExistsFalse(): void + { + $this->ldapAdapter->method('findUserDN') + ->willThrowException(new \Horde_Ldap_Exception('User not found')); + + $this->assertFalse($this->service->exists('nonexistent')); + } + + public function testAttributeNaming(): void + { + // Test that attribute names are properly formatted + $this->ldapAdapter->method('findUserDN')->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createMock(Horde_Ldap_Search::class); + $search->method('count')->willReturn(0); + $this->ldapAdapter->method('search')->willReturn($search); + + $entry = $this->createMock(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn(['inetOrgPerson']); + $entry->expects($this->exactly(2))->method('replace') + ->withConsecutive( + [['objectClass' => $this->anything()]], + [['hordePrefImpSentFolder' => '/Sent']] // Capital I for Imp, capital S for SentFolder + ); + $entry->method('update'); + + $this->ldapAdapter->method('getEntry')->willReturn($entry); + + $this->service->setValue('alice', 'imp', 'sentFolder', '/Sent'); + } +} diff --git a/test/Service/MockGroupServiceTest.php b/test/Service/MockGroupServiceTest.php new file mode 100644 index 00000000..0380a30c --- /dev/null +++ b/test/Service/MockGroupServiceTest.php @@ -0,0 +1,306 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\MockGroupService; +use Horde\Core\Service\Exception\GroupNotFoundException; +use Horde\Core\Service\Exception\GroupExistsException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Tests for MockGroupService + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(MockGroupService::class)] +class MockGroupServiceTest extends TestCase +{ + private MockGroupService $service; + + protected function setUp(): void + { + $this->service = new MockGroupService(); + } + + public function testCreateGroup(): void + { + $group = $this->service->create('Developers'); + + $this->assertNotEmpty($group->id); + $this->assertEquals('Developers', $group->name); + $this->assertEmpty($group->members); + $this->assertFalse($this->service->isReadOnly()); + } + + public function testCreateDuplicateGroupThrows(): void + { + $this->service->create('Developers'); + + $this->expectException(GroupExistsException::class); + $this->service->create('Developers'); + } + + public function testGetGroupById(): void + { + $created = $this->service->create('Team'); + $group = $this->service->get($created->id); + + $this->assertEquals($created->id, $group->id); + $this->assertEquals('Team', $group->name); + } + + public function testGetGroupByName(): void + { + $this->service->create('Admins'); + $group = $this->service->get('Admins'); + + $this->assertEquals('Admins', $group->name); + } + + public function testGetNonExistentGroupThrows(): void + { + $this->expectException(GroupNotFoundException::class); + $this->service->get('nonexistent'); + } + + public function testExistsById(): void + { + $group = $this->service->create('TestGroup'); + + $this->assertTrue($this->service->exists($group->id)); + $this->assertFalse($this->service->exists('nonexistent')); + } + + public function testExistsByName(): void + { + $this->service->create('TestGroup'); + + $this->assertTrue($this->service->exists('TestGroup')); + $this->assertFalse($this->service->exists('OtherGroup')); + } + + public function testDeleteGroupById(): void + { + $group = $this->service->create('ToDelete'); + $this->assertTrue($this->service->exists($group->id)); + + $this->service->delete($group->id); + + $this->assertFalse($this->service->exists($group->id)); + } + + public function testDeleteGroupByName(): void + { + $this->service->create('ToDelete'); + $this->assertTrue($this->service->exists('ToDelete')); + + $this->service->delete('ToDelete'); + + $this->assertFalse($this->service->exists('ToDelete')); + } + + public function testDeleteNonExistentGroupThrows(): void + { + $this->expectException(GroupNotFoundException::class); + $this->service->delete('nonexistent'); + } + + public function testListAllEmpty(): void + { + $result = $this->service->listAll(); + + $this->assertEmpty($result->groups); + $this->assertEquals(0, $result->total); + } + + public function testListAllWithGroups(): void + { + $this->service->create('Group1'); + $this->service->create('Group2'); + $this->service->create('Group3'); + + $result = $this->service->listAll(); + + $this->assertCount(3, $result->groups); + $this->assertEquals(3, $result->total); + $this->assertEquals(1, $result->page); + $this->assertEquals(50, $result->perPage); + $this->assertFalse($result->hasNext); + $this->assertFalse($result->hasPrev); + } + + public function testListAllPagination(): void + { + // Create 25 groups + for ($i = 1; $i <= 25; $i++) { + $this->service->create("Group$i"); + } + + // Page 1 + $result = $this->service->listAll(1, 10); + $this->assertCount(10, $result->groups); + $this->assertEquals(25, $result->total); + $this->assertTrue($result->hasNext); + $this->assertFalse($result->hasPrev); + + // Page 2 + $result = $this->service->listAll(2, 10); + $this->assertCount(10, $result->groups); + $this->assertTrue($result->hasNext); + $this->assertTrue($result->hasPrev); + + // Page 3 (last page, partial) + $result = $this->service->listAll(3, 10); + $this->assertCount(5, $result->groups); + $this->assertFalse($result->hasNext); + $this->assertTrue($result->hasPrev); + } + + public function testAddMember(): void + { + $group = $this->service->create('Team'); + $this->service->addMember($group->id, 'alice'); + + $members = $this->service->getMembers($group->id); + $this->assertContains('alice', $members); + } + + public function testAddMemberIdempotent(): void + { + $group = $this->service->create('Team'); + $this->service->addMember($group->id, 'alice'); + $this->service->addMember($group->id, 'alice'); // Duplicate + + $members = $this->service->getMembers($group->id); + $this->assertCount(1, $members); + $this->assertEquals(['alice'], $members); + } + + public function testAddMemberByGroupName(): void + { + $this->service->create('Team'); + $this->service->addMember('Team', 'bob'); + + $members = $this->service->getMembers('Team'); + $this->assertContains('bob', $members); + } + + public function testAddMemberToNonExistentGroupThrows(): void + { + $this->expectException(GroupNotFoundException::class); + $this->service->addMember('nonexistent', 'alice'); + } + + public function testAddMembers(): void + { + $group = $this->service->create('Team'); + $this->service->addMembers($group->id, ['alice', 'bob', 'charlie']); + + $members = $this->service->getMembers($group->id); + $this->assertCount(3, $members); + $this->assertContains('alice', $members); + $this->assertContains('bob', $members); + $this->assertContains('charlie', $members); + } + + public function testRemoveMember(): void + { + $group = $this->service->create('Team'); + $this->service->addMembers($group->id, ['alice', 'bob']); + + $this->service->removeMember($group->id, 'alice'); + + $members = $this->service->getMembers($group->id); + $this->assertNotContains('alice', $members); + $this->assertContains('bob', $members); + } + + public function testRemoveMemberIdempotent(): void + { + $group = $this->service->create('Team'); + $this->service->addMember($group->id, 'alice'); + + $this->service->removeMember($group->id, 'alice'); + $this->service->removeMember($group->id, 'alice'); // Already removed + + $members = $this->service->getMembers($group->id); + $this->assertEmpty($members); + } + + public function testRemoveMembers(): void + { + $group = $this->service->create('Team'); + $this->service->addMembers($group->id, ['alice', 'bob', 'charlie']); + + $this->service->removeMembers($group->id, ['alice', 'charlie']); + + $members = $this->service->getMembers($group->id); + $this->assertCount(1, $members); + $this->assertEquals(['bob'], $members); + } + + public function testSetMembers(): void + { + $group = $this->service->create('Team'); + $this->service->addMembers($group->id, ['alice', 'bob']); + + $this->service->setMembers($group->id, ['charlie', 'dave']); + + $members = $this->service->getMembers($group->id); + $this->assertCount(2, $members); + $this->assertContains('charlie', $members); + $this->assertContains('dave', $members); + $this->assertNotContains('alice', $members); + $this->assertNotContains('bob', $members); + } + + public function testSetMembersRemovesDuplicates(): void + { + $group = $this->service->create('Team'); + $this->service->setMembers($group->id, ['alice', 'bob', 'alice']); // Duplicate + + $members = $this->service->getMembers($group->id); + $this->assertCount(2, $members); + } + + public function testFixturesConstructor(): void + { + $service = new MockGroupService([ + ['name' => 'Team1', 'members' => ['alice', 'bob']], + ['name' => 'Team2', 'members' => ['charlie']], + ]); + + $this->assertTrue($service->exists('Team1')); + $this->assertTrue($service->exists('Team2')); + + $members = $service->getMembers('Team1'); + $this->assertEquals(['alice', 'bob'], $members); + } + + public function testGetMembersReturnsEmptyArrayForNewGroup(): void + { + $group = $this->service->create('Empty'); + + $members = $this->service->getMembers($group->id); + $this->assertIsArray($members); + $this->assertEmpty($members); + } +} diff --git a/test/Service/MongoPrefsServiceTest.php b/test/Service/MongoPrefsServiceTest.php new file mode 100644 index 00000000..540d3429 --- /dev/null +++ b/test/Service/MongoPrefsServiceTest.php @@ -0,0 +1,219 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Service; + +use Horde\Core\Service\MongoPrefsService; +use Horde_Mongo_Client; +use MongoCollection; +use MongoException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use RuntimeException; + +/** + * Tests for MongoPrefsService + * + * These tests verify the service structure and error handling. + * Actual MongoDB integration requires mongodb extension and running server. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(MongoPrefsService::class)] +class MongoPrefsServiceTest extends TestCase +{ + public function testConstructorThrowsOnConnectionFailure(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + // Create mock that throws MongoException on selectCollection + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willThrowException(new MongoException('Connection refused')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to connect to MongoDB'); + + new MongoPrefsService($mockClient); + } + + public function testGetValueThrowsOnQueryError(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('findOne') + ->willThrowException(new MongoException('Query failed')); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MongoDB query failed'); + + $service->getValue('user1', 'horde', 'theme'); + } + + public function testSetValueThrowsOnUpdateError(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('update') + ->willThrowException(new MongoException('Update failed')); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MongoDB update failed'); + + $service->setValue('user1', 'horde', 'theme', 'silver'); + } + + public function testDeleteValueThrowsOnRemoveError(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('remove') + ->willThrowException(new MongoException('Remove failed')); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MongoDB delete failed'); + + $service->deleteValue('user1', 'horde', 'theme'); + } + + public function testGetAllInScopeThrowsOnQueryError(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('find') + ->willThrowException(new MongoException('Query failed')); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MongoDB query failed'); + + $service->getAllInScope('user1', 'horde'); + } + + public function testExistsThrowsOnQueryError(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('count') + ->willThrowException(new MongoException('Query failed')); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MongoDB query failed'); + + $service->exists('user1', 'horde', 'theme'); + } + + public function testIndexCreationFailureDoesNotThrow(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('ensureIndex') + ->willThrowException(new MongoException('Index creation failed')); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->method('selectCollection') + ->willReturn($mockCollection); + + // Index creation failure should not throw from constructor + // (it's logged but non-fatal) + $service = new MongoPrefsService($mockClient); + + $this->assertInstanceOf(MongoPrefsService::class, $service); + } + + public function testCustomCollectionName(): void + { + if (!class_exists('MongoException')) { + $this->markTestSkipped('MongoDB extension not available'); + } + + $mockCollection = $this->createMock(MongoCollection::class); + $mockCollection->method('ensureIndex') + ->willReturn(true); + + $mockClient = $this->createMock(Horde_Mongo_Client::class); + $mockClient->expects($this->once()) + ->method('selectCollection') + ->with(null, 'custom_prefs') + ->willReturn($mockCollection); + + $service = new MongoPrefsService($mockClient, 'custom_prefs'); + + $this->assertInstanceOf(MongoPrefsService::class, $service); + } +} diff --git a/test/SmartmobileUrlTest.php b/test/SmartmobileUrlTest.php index 9fae2156..cbaf75dd 100644 --- a/test/SmartmobileUrlTest.php +++ b/test/SmartmobileUrlTest.php @@ -3,7 +3,6 @@ namespace Horde\Core\Test; use Horde\Test\TestCase; - use Horde_Core_Smartmobile_Url as SmartmobileUrl; use Horde_Url; diff --git a/test/Stub/Registryconfig.php b/test/Stub/Registryconfig.php index 52427c2b..6ebac4b2 100644 --- a/test/Stub/Registryconfig.php +++ b/test/Stub/Registryconfig.php @@ -1,4 +1,5 @@ tempTemplate, [ - 'message' => 'Hello World' + 'message' => 'Hello World', ]); // Render @@ -68,7 +68,7 @@ public function testRenderWithMultipleVariables(): void $view = new ResponsiveTemplateView($this->tempTemplate, [ 'greeting' => 'Hello', - 'name' => 'Horde' + 'name' => 'Horde', ]); $output = $view->render(); @@ -78,17 +78,17 @@ public function testRenderWithMultipleVariables(): void public function testRenderWithHtmlTemplate(): void { $template = <<<'HTML' - - -<?php echo $title; ?> -

- -HTML; + + + <?php echo $title; ?> +

+ + HTML; file_put_contents($this->tempTemplate, $template); $view = new ResponsiveTemplateView($this->tempTemplate, [ 'title' => 'Test Page', - 'heading' => 'Welcome' + 'heading' => 'Welcome', ]); $output = $view->render(); @@ -101,7 +101,7 @@ public function testMagicGetterAccess(): void file_put_contents($this->tempTemplate, 'property ?>'); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'property' => 'value123' + 'property' => 'value123', ]); $output = $view->render(); @@ -123,7 +123,7 @@ public function testMagicIsset(): void file_put_contents($this->tempTemplate, 'exists) ? "yes" : "no"; ?>'); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'exists' => 'value' + 'exists' => 'value', ]); $output = $view->render(); @@ -156,7 +156,7 @@ public function testGetData(): void $data = [ 'name' => 'Test', 'value' => 123, - 'array' => [1, 2, 3] + 'array' => [1, 2, 3], ]; file_put_contents($this->tempTemplate, ''); @@ -170,7 +170,7 @@ public function testEscapeHelper(): void file_put_contents($this->tempTemplate, 'escape($html) ?>'); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'html' => '' + 'html' => '', ]); $output = $view->render(); @@ -203,7 +203,7 @@ public function testEscapeAttrHelper(): void file_put_contents($this->tempTemplate, '
'); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'attr' => 'value"onclick="alert(1)' + 'attr' => 'value"onclick="alert(1)', ]); $output = $view->render(); @@ -217,7 +217,7 @@ public function testEscapeUrlHelper(): void file_put_contents($this->tempTemplate, 'Link'); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'url' => 'http://example.com?foo=bar&baz=qux' + 'url' => 'http://example.com?foo=bar&baz=qux', ]); $output = $view->render(); @@ -242,12 +242,12 @@ public function testRenderThrowsExceptionOnTemplateError(): void public function testRenderCapturesAllOutput(): void { $template = <<<'PHP' - -PHP; + + PHP; file_put_contents($this->tempTemplate, $template); $view = new ResponsiveTemplateView($this->tempTemplate, []); @@ -261,14 +261,14 @@ public function testRenderCapturesAllOutput(): void public function testRenderWithLoop(): void { $template = <<<'PHP' - -
  • escape($item) ?>
  • - -PHP; + +
  • escape($item) ?>
  • + + PHP; file_put_contents($this->tempTemplate, $template); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'items' => ['Item 1', 'Item 2', 'Item 3'] + 'items' => ['Item 1', 'Item 2', 'Item 3'], ]); $output = $view->render(); @@ -280,16 +280,16 @@ public function testRenderWithLoop(): void public function testRenderWithConditional(): void { $template = <<<'PHP' - -

    Visible

    - -

    Hidden

    - -PHP; + +

    Visible

    + +

    Hidden

    + + PHP; file_put_contents($this->tempTemplate, $template); $view = new ResponsiveTemplateView($this->tempTemplate, [ - 'show' => true + 'show' => true, ]); $output = $view->render(); diff --git a/test/bootstrap-minimal.php b/test/bootstrap-minimal.php index 3186e603..e4153b4b 100644 --- a/test/bootstrap-minimal.php +++ b/test/bootstrap-minimal.php @@ -1,16 +1,28 @@