From 3bd9cd5c0bdd127ac06790dc2445b2c35a108f5e Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 15 Mar 2026 13:57:22 +0100 Subject: [PATCH] feat(service): Provide PSR-4 service layer without registry instantiation. Provide a modern interface-based DI Service layer. Manager style factories read different kinds of config files such as registry, prefs, backends and conf.php from their canonical multiple layers. Based on this configuration layer, connection pool manager style factories expose foundation services such as "Db" and "Ldap". On top of these, modern factories for Auth, Prefs, Identity, Group, Permission and Introspection are provided. All new code is PSR-4 and PER-1 compliant. Access to config/registry/injector GLOBALS was largely avoided. For integration with the existing framework, the new factories are registered in the legacy Registry.php Injector bootstrap. No core functionality of Horde for user productivity currently uses this new infrastructure and no removal/porting of existing lib/ factories is currently planned. --- .gitignore | 3 +- composer.json | 13 +- lib/Horde/Registry.php | 10 + src/Auth/AuthNotSupportedException.php | 34 ++ src/Auth/AuthService.php | 197 +++++++ src/Factory/ApplicationServiceFactory.php | 38 ++ src/Factory/AuthServiceFactory.php | 125 ++++ src/Factory/ConfigLoaderFactory.php | 45 ++ src/Factory/DbServiceFactory.php | 212 +++++++ src/Factory/GroupServiceFactory.php | 50 ++ src/Factory/HordeLdapServiceFactory.php | 184 ++++++ src/Factory/IdentityServiceFactory.php | 46 ++ src/Factory/LdapGroupServiceFactory.php | 65 +++ src/Factory/LdapPrefsServiceFactory.php | 61 ++ src/Factory/PermissionServiceFactory.php | 105 ++++ src/Factory/PrefsServiceFactory.php | 137 +++++ src/Factory/RegistryConfigLoaderFactory.php | 40 ++ src/Service/ApplicationService.php | 161 +++++ .../Exception/GroupExistsException.php | 29 + .../Exception/GroupNotFoundException.php | 29 + .../Exception/PermissionNotFoundException.php | 31 + src/Service/FileGroupService.php | 255 ++++++++ src/Service/FilePrefsService.php | 202 +++++++ src/Service/GroupInfo.php | 79 +++ src/Service/GroupListResult.php | 50 ++ src/Service/GroupService.php | 156 +++++ src/Service/HordeDbService.php | 40 ++ src/Service/HordeLdapService.php | 40 ++ src/Service/IdentityNotFoundException.php | 31 + src/Service/IdentityService.php | 173 ++++++ src/Service/LdapGroupService.php | 425 ++++++++++++++ src/Service/LdapPrefsService.php | 296 ++++++++++ src/Service/MockGroupService.php | 331 +++++++++++ src/Service/MongoPrefsService.php | 265 +++++++++ src/Service/NullPermissionService.php | 154 +++++ src/Service/NullPrefsService.php | 103 ++++ src/Service/PermissionService.php | 135 +++++ src/Service/PrefsService.php | 84 +++ src/Service/SqlGroupService.php | 319 ++++++++++ src/Service/SqlPermissionService.php | 551 ++++++++++++++++++ src/Service/SqlPrefsService.php | 181 ++++++ src/Service/StandardHordeDbService.php | 53 ++ src/Service/StandardHordeLdapService.php | 53 ++ test/ActiveSyncTests.php | 94 +-- test/Assets/ResponsiveAssetsTest.php | 22 +- test/Authentication/Method/BasicTest.php | 35 +- test/Config/BackendConfigLoaderTest.php | 264 +++++---- test/Config/VhostTest.php | 2 +- test/ConfigStateTest.php | 1 + test/Factory/GroupTest.php | 1 + test/Factory/HordeLdapServiceFactoryTest.php | 197 +++++++ test/Factory/KolabServerTest.php | 1 + test/Factory/KolabSessionTest.php | 1 + test/Middleware/AppFinderTest.php | 1 - test/Middleware/AppRouterTest.php | 71 ++- test/Middleware/AuthHordeSessionTest.php | 3 +- test/Middleware/AuthIsGlobalAdminTest.php | 73 ++- test/Middleware/DemandAuthHeaderTest.php | 2 +- .../DemandAuthenticatedUserTest.php | 2 +- test/Middleware/DemandSessionTokenTest.php | 3 +- test/Middleware/RedirectToLoginTest.php | 1 - test/Middleware/SetUpTrait.php | 2 +- test/Mock/MockIMPMailbox.php | 4 +- test/NlsconfigTest.php | 2 +- test/RampageIntegrationTest.php | 41 +- test/RegistryTest.php | 4 +- test/Service/FileGroupServiceTest.php | 313 ++++++++++ test/Service/FilePrefsServiceTest.php | 275 +++++++++ test/Service/LdapGroupServiceTest.php | 264 +++++++++ test/Service/LdapPrefsServiceTest.php | 285 +++++++++ test/Service/MockGroupServiceTest.php | 306 ++++++++++ test/Service/MongoPrefsServiceTest.php | 219 +++++++ test/SmartmobileUrlTest.php | 1 - test/Stub/Registryconfig.php | 1 + test/View/ResponsiveTemplateViewTest.php | 66 +-- test/bootstrap-minimal.php | 22 +- test/bootstrap.php | 1 + 77 files changed, 7835 insertions(+), 336 deletions(-) create mode 100644 src/Auth/AuthNotSupportedException.php create mode 100644 src/Auth/AuthService.php create mode 100644 src/Factory/ApplicationServiceFactory.php create mode 100644 src/Factory/AuthServiceFactory.php create mode 100644 src/Factory/ConfigLoaderFactory.php create mode 100644 src/Factory/DbServiceFactory.php create mode 100644 src/Factory/GroupServiceFactory.php create mode 100644 src/Factory/HordeLdapServiceFactory.php create mode 100644 src/Factory/IdentityServiceFactory.php create mode 100644 src/Factory/LdapGroupServiceFactory.php create mode 100644 src/Factory/LdapPrefsServiceFactory.php create mode 100644 src/Factory/PermissionServiceFactory.php create mode 100644 src/Factory/PrefsServiceFactory.php create mode 100644 src/Factory/RegistryConfigLoaderFactory.php create mode 100644 src/Service/ApplicationService.php create mode 100644 src/Service/Exception/GroupExistsException.php create mode 100644 src/Service/Exception/GroupNotFoundException.php create mode 100644 src/Service/Exception/PermissionNotFoundException.php create mode 100644 src/Service/FileGroupService.php create mode 100644 src/Service/FilePrefsService.php create mode 100644 src/Service/GroupInfo.php create mode 100644 src/Service/GroupListResult.php create mode 100644 src/Service/GroupService.php create mode 100644 src/Service/HordeDbService.php create mode 100644 src/Service/HordeLdapService.php create mode 100644 src/Service/IdentityNotFoundException.php create mode 100644 src/Service/IdentityService.php create mode 100644 src/Service/LdapGroupService.php create mode 100644 src/Service/LdapPrefsService.php create mode 100644 src/Service/MockGroupService.php create mode 100644 src/Service/MongoPrefsService.php create mode 100644 src/Service/NullPermissionService.php create mode 100644 src/Service/NullPrefsService.php create mode 100644 src/Service/PermissionService.php create mode 100644 src/Service/PrefsService.php create mode 100644 src/Service/SqlGroupService.php create mode 100644 src/Service/SqlPermissionService.php create mode 100644 src/Service/SqlPrefsService.php create mode 100644 src/Service/StandardHordeDbService.php create mode 100644 src/Service/StandardHordeLdapService.php create mode 100644 test/Factory/HordeLdapServiceFactoryTest.php create mode 100644 test/Service/FileGroupServiceTest.php create mode 100644 test/Service/FilePrefsServiceTest.php create mode 100644 test/Service/LdapGroupServiceTest.php create mode 100644 test/Service/LdapPrefsServiceTest.php create mode 100644 test/Service/MockGroupServiceTest.php create mode 100644 test/Service/MongoPrefsServiceTest.php 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 @@