diff --git a/doc/GREENFIELD_BEST_PRACTICES.md b/doc/GREENFIELD_BEST_PRACTICES.md index 5254900d..f02cb7b7 100644 --- a/doc/GREENFIELD_BEST_PRACTICES.md +++ b/doc/GREENFIELD_BEST_PRACTICES.md @@ -241,6 +241,16 @@ global state. ## Modern replacements +### Config access + +Use the Horde\Core\Config\ConfigLoader instead of the legacy $conf global. +By default apps come with a Horde\$app\$AppConfig object wired up in the DI container which wraps this app's config object. + +## Preferences access + +Prefer type hinting against the Horde\Core\Service\PrefsService class over using the legacy Horde_Prefs DI hint or the $prefs global. +See also PREFERENCES.md + ### Horde\Db over Horde_Db Use `Horde\Db` (the namespaced package) for all new database work: diff --git a/doc/PREFERENCES.md b/doc/PREFERENCES.md new file mode 100644 index 00000000..2fa4b669 --- /dev/null +++ b/doc/PREFERENCES.md @@ -0,0 +1,379 @@ +# Preferences System + +This document describes the modern `PrefsService` API and how to migrate from +directly using the legacy `Horde_Prefs` / `Horde_Core_Factory_Prefs`. + +It also documents the cascade of file-based defaults and user preferences. + +--- + +## 1. Overview + +Preferences in Horde are **scoped by user and application**. Every pref +lives at the intersection of three coordinates: + +``` +(uid, scope, key) -> value +``` + +`uid` is the authenticating username string. `scope` is the application name +(`'horde'`, `'imp'`, `'turba'` etc). `key` is the preference name from an application's +`prefs.php` (`'theme'`, `'language'`, `'default_tasklist'`). + +The Horde 6 entry point is the `Horde\Core\Service\PrefsService` interface. +Inject it where you need it. The DI container selects the correct backend +(SQL, LDAP, file etc) at runtime based on the horde base app's `conf.php`. + +--- + +## 2. The PrefsService Interface + +```php +namespace Horde\Core\Service; + +interface PrefsService +{ + /** Returns stored or config-default value, null if completely absent. */ + public function getValue(string $uid, string $scope, string $key); + + /** + * Write a value to the user storage layer. + * Throws RuntimeException if the pref is locked in config. + */ + public function setValue(string $uid, string $scope, string $key, $value): void; + + /** Remove the user-level row. No-op if the row does not exist. */ + public function deleteValue(string $uid, string $scope, string $key): void; + + /** + * Returns every effective value in a scope as key => value. + * Config defaults appear even when no DB row exists; locked prefs + * use the config value regardless of any stale DB row. + */ + public function getAllInScope(string $uid, string $scope): array; + + /** True if the pref exists (config default or user row). */ + public function exists(string $uid, string $scope, string $key): bool; + + /** + * True if the pref is locked by a config layer. + * Lock state is deployment-wide, so $uid does not change the result. + */ + public function isLocked(string $uid, string $scope, string $key): bool; +} +``` + +**Important difference from legacy `$prefs` global:** + +| `$prefs` (legacy) | `PrefsService` | +|---|---| +| User from session, implicit | `$uid` explicit on every call | +| Scope from `appInit()`, implicit | `$scope` explicit on every call | +| Missing key → empty string `''` | Missing key → `null` | +| Hooks run automatically | Storage/retrieval only | +| Singleton, cached per request | Stateless. No per-call caching | + +--- + +## 3. Available Backends + +All backends implement `PrefsService`. The right one is selected automatically +by `PrefsServiceFactory` based on `$conf['prefs']['driver']`. + +| Class | Driver value | Notes | +|---|---|---| +| `SqlPrefsService` | `sql` | Stores in a `horde_prefs` sql table. Applies full 5-layer config cascade via `PrefsConfigLoader`. Default. | +| `FilePrefsService` | `file` | Serialised PHP arrays at `{dir}/{scope}/{uid}.prefs`. No config cascade, no locking or defaults. | +| `LdapPrefsService` | `ldap` | LDAP attributes using `hordePerson` objectClass.| +| `MongoPrefsService` | `nosql` | MongoDB documents. | +| `NullPrefsService` | `null`, `session` | In-memory only. Nothing persists. For testing or minimal sites. | + +--- + +## 4. Factory Architecture + +### 4.1 Root factory in `horde/core` + +`Horde\Core\Factory\PrefsServiceFactory` (in `src/Factory/`) is the +authoritative factory for the H6 API. It is registered in +`DefaultInjectorBindings`: + +```php +PrefsService::class => PrefsServiceFactory::class, +``` + +When `$injector->get(PrefsService::class)` is called, the factory: + +1. Loads `horde/conf.php` via `ConfigLoader`. +2. Reads `$conf['prefs']['driver']` and `$conf['prefs']['params']`. +3. Constructs the matching `*PrefsService` implementation. +4. For the SQL, LDAP and Mongo backends it also resolves `PrefsConfigLoader` from the container + and passes it into `SqlPrefsService`. + +``` +Injector::get(PrefsService::class) + └─ PrefsServiceFactory::create() + ├─ ConfigLoader → reads conf.php → driver = 'sql' + ├─ HordeDbService::getAdapter() + ├─ PrefsConfigLoader ← resolved from container via PrefsConfigLoaderFactory + └─ new SqlPrefsService($db, $prefsConfigLoader, $table) +``` + +`PrefsConfigLoader` itself is registered as: + +```php +PrefsConfigLoader::class => PrefsConfigLoaderFactory::class, +``` + +`PrefsConfigLoaderFactory` reads `HORDE_CONFIG_BASE` and `HORDE_BASE` to +locate each app's `prefs.php` files, mirrors the pattern of +`BackendConfigLoaderFactory`. + +### 4.2 Legacy factory in `lib/` (`Horde_Core_Factory_Prefs`) + +`Horde_Core_Factory_Prefs` (`lib/Horde/Core/Factory/Prefs.php`) is the +**pre-existing legacy factory**. It creates `Horde_Prefs` instances +directly, wiring three drivers: + +``` +[Horde_Core_Prefs_Storage_Configuration, $userStorageDriver, Horde_Core_Prefs_Storage_Hooks] +``` + +Legacy code that calls `$GLOBALS['injector']->get('Horde_Core_Factory_Prefs')->create($scope)` +or accesses the `$prefs` global still goes through this factory. It requires the legacy application bootstrap and consumes the globals `$GLOBALS['registry']` and `$GLOBALS['conf']`. + +`Horde_Core_Prefs_Storage_Configuration` is the config layer in the legacy +stack. It has a guard for global registry: + +```php +if (!$registry instanceof Horde_Registry) { + return $scope_ob; // no defaults, no locking +} +``` + +This makes it a silent no-op on any PSR-15 route that skips `appInit()`. +The modern `SqlPrefsService` uses `PrefsConfigLoaderStorage` and has no such dependency. + +### 4.3 Coexistence during migration + +Both factories can coexist in the same process. A partially-migrated app may +have: + +- Legacy templates and AJAX handlers using `$GLOBALS['prefs']` (legacy stack). +- New PSR-15 controllers injecting `PrefsService` (modern stack). + +Both read from the same underlying `horde_prefs` table or LDAP resource. +Write through either path lands in the same rows. + +The key behavioural difference is that only `SqlPrefsService` correctly +surfaces config defaults on PSR-15 routes. If a new route reads a pref that +has a `'value'` in `prefs.php` but no user row, `PrefsService::getValue()` +returns the default; the legacy `$prefs->getValue()` on the same route returns +`null` (because its config driver is a no-op without `$GLOBALS['registry']`). + +--- + +## 5. Loading Precedence / Stacking + +Effective preference values are assembled in two phases: a **config file +cascade** followed by **user storage**. + +### 5.1 Config file cascade (five layers) + +`PrefsConfigLoader` merges `prefs.php` files in this order. Each layer +overwrites keys set by earlier layers. + +``` +Layer 1 vendor/horde/{app}/config/prefs.php ← package defaults +Layer 2 {HORDE_CONFIG_BASE}/{app}/prefs.php ← deployment base +Layer 3 {HORDE_CONFIG_BASE}/{app}/prefs.d/*.php ← snippets, alphabetical +Layer 4 {HORDE_CONFIG_BASE}/{app}/prefs.local.php ← local override +Layer 5 {HORDE_CONFIG_BASE}/{app}/prefs-{hostname}.php ← vhost override +``` + +Snippets in `prefs.d/` are `sort()`ed before inclusion, guaranteeing +alphabetical (and therefore deterministic) merge order regardless of +filesystem. + +The result is a `PrefsState` object: an immutable snapshot of every pref +definition (`'value'`, `'locked'`, `'type'`, `'hook'`, …) after all five +layers have been merged. + +### 5.2 User storage layer (sixth layer) + +`Horde_Prefs_Storage_Sql` applies user-specific values on top of the config +defaults. Only **unlocked** prefs are overwritten; locked prefs keep their +config-layer value even if a DB row exists. + +``` +Config default from prefs.php cascade (layers 1-5) + │ + │ if not locked: DB row overwrites default + │ + └Effective value +``` + +The full driver stack inside `SqlPrefsService::createPrefs()` is: + +``` +[$configDriver, $sqlDriver ] + │ │ + │ sets defaults │ overwrites unlocked prefs + │ sets locks │ (reads horde_prefs table) + └ Horde_Prefs applies them left-to-right -> effective value +``` + +`$configDriver` is `PrefsConfigLoaderStorage`, which calls +`PrefsConfigLoader::load($scope)` and does not require `$GLOBALS['registry']`. + +### 5.3 getAllInScope() stacking + +`getAllInScope()` builds the same merged view without instantiating +`Horde_Prefs`: + +1. Seed result from config defaults (`PrefsState::getAllPrefs()`). + UI-only types (`link`, `prefslink`, `rawhtml`, `container`, `special`) and + prefs without a `'value'` key are skipped. +2. Track locked keys in a separate set. +3. Run the SQL query for the given `(uid, scope)`. +4. For each DB row: + - If the key is locked → skip (config default wins). + - Otherwise → overwrite the config default with the DB value. +5. DB rows for keys not present in the config are included as-is (custom or + deprecated prefs). + +### 5.4 Lock semantics + +A pref is locked when any config layer sets `'locked' => true`. Locking is +**deployment-wide**. It applies to every user equally. The `$uid` parameter +on `isLocked()` exists only to satisfy the interface contract. It has no +effect on the result. + +Locked prefs: +- `getValue()` → returns the config-layer value, never the DB row. +- `getAllInScope()` → same; DB row silently ignored. +- `isLocked()` → `true`. +- `setValue()` → throws `RuntimeException`. +- `deleteValue()` → succeeds (removes the stale row) but the config value is + restored on the next read. + +--- + +## 6. Converting Legacy `$prefs` / `Horde_Prefs` References + +### 6.1 Simple read/write in a controller + +```php +// Before -> legacy global, implicit user and scope +global $prefs; +$theme = $prefs->getValue('theme'); // user from session +$prefs->setValue('theme', 'silver'); // scope from appInit() + +// After -> injected service, explicit coordinates +use Horde\Core\Service\PrefsService; + +class ThemeController +{ + public function __construct( + private readonly PrefsService $prefsService, + ) {} + + public function getTheme(string $uid): string + { + return $this->prefsService->getValue($uid, 'horde', 'theme') ?? 'default'; + } + + public function setTheme(string $uid, string $theme): void + { + $this->prefsService->setValue($uid, 'horde', 'theme', $theme); + } +} +``` + +### 6.2 Reading the config default without a user row + +`PrefsService::getValue()` returns the config-file default even when no DB row +exists. The legacy `$prefs->getValue()` did the same, but only when +`$GLOBALS['registry']` was populated. On a PSR-15 route the legacy call +would silently return `null`; the modern call always returns the default. + +```php +// No DB row for this user -> returns 'en_US' from prefs.php +$lang = $this->prefsService->getValue($uid, 'horde', 'language'); +``` + +### 6.3 Checking and honouring locks + +```php +// Guard a write in code that was previously unaware of locking +if (!$this->prefsService->isLocked($uid, 'horde', 'theme')) { + $this->prefsService->setValue($uid, 'horde', 'theme', $requested); +} + +// Or let setValue() throw and catch it at the boundary +try { + $this->prefsService->setValue($uid, 'horde', 'theme', $requested); +} catch (\RuntimeException $e) { + // pref is locked. Ignore or surface to the caller +} +``` + +### 6.4 Bulk read of all prefs in a scope + +```php +// Before +global $prefs; +$prefs->changeScope('nag'); +$all = iterator_to_array($prefs); // not actually supported cleanly + +// After +$all = $this->prefsService->getAllInScope($uid, 'nag'); +// Returns config defaults + user values, locked prefs at their config value +``` + +### 6.5 Migration checklist + +When migrating a class that currently uses `$prefs` or `Horde_Core_Factory_Prefs`: + +- [ ] Add `PrefsService` to the constructor. +- [ ] Replace every `$prefs->getValue($key)` with + `$this->prefsService->getValue($uid, $scope, $key)`. + Supply the real `$uid` (from auth context) and `$scope` (the app name). +- [ ] Replace `$prefs->setValue($key, $value)` with + `$this->prefsService->setValue($uid, $scope, $key, $value)`. +- [ ] Replace `$prefs->isLocked($key)` with + `$this->prefsService->isLocked($uid, $scope, $key)`. +- [ ] Remove any `global $prefs;` / `global $registry;` declarations that + existed solely to reach preferences. +- [ ] Verify that the class no longer calls `appInit()` only to get `$prefs`. +- [ ] Update unit tests: inject `NullPrefsService` or a PHPUnit mock; no + global bootstrap required. + +### 6.6 What PrefsService does not replace + +`PrefsService` is storage/retrieval only. The following legacy features have +**no direct equivalent** in the interface and must be handled separately: + +| Feature | Legacy mechanism | Modern path | +|---|---|---| +| Hooks (`'hook' => true`) | `Horde_Core_Prefs_Storage_Hooks` driver | Explicit hook invocation in application code | +| Session-scoped cache | `Horde_Prefs_Cache_HordeCache` | Not needed; `PrefsService` is stateless | +| Size callback | `$conf['prefs']['maxsize']` | Application-level validation before `setValue()` | +| Preference UI rendering | `Horde_Core_Prefs_Ui` | Unchanged; still uses legacy stack for forms | + +--- + +## 7. Configuration Reference + +`conf.php` knobs consumed by `PrefsServiceFactory`: + +```php +$conf['prefs']['driver'] // 'sql' | 'ldap' | 'nosql' | 'null' | 'session' +$conf['prefs']['params']['table'] // SQL table name, default 'horde_prefs' +``` + +`PrefsConfigLoader` is configured automatically from the constants +`HORDE_CONFIG_BASE` (deployment config root) and `HORDE_BASE` +(path to `vendor/horde/horde`; parent directory is used as `vendorBase`). +Both constants are set during bootstrap; the factory falls back to +`/etc/horde` and `__DIR__/../../../..` respectively if they are not defined. diff --git a/doc/UPGRADING_UI.md b/doc/UPGRADING_UI.md index 70eb20f6..224668d6 100644 --- a/doc/UPGRADING_UI.md +++ b/doc/UPGRADING_UI.md @@ -601,6 +601,10 @@ Examples: ## 8. Replacing Horde_Prefs / $prefs with PrefsService +> **Full reference** — config cascade, factory architecture, lock semantics, +> and a complete migration checklist are in [PREFERENCES.md](PREFERENCES.md). +> This section summarises the mechanical substitution steps. + ### Old pattern ```php diff --git a/src/DefaultInjectorBindings.php b/src/DefaultInjectorBindings.php index 1af33211..7774ad11 100644 --- a/src/DefaultInjectorBindings.php +++ b/src/DefaultInjectorBindings.php @@ -26,6 +26,7 @@ use Horde\Core\Config\ConfigMetadataProvider; use Horde\Core\Config\BackendConfigLoader; use Horde\Core\Config\MotdLoader; +use Horde\Core\Config\PrefsConfigLoader; use Horde\Core\Config\Driver\DriverRepository; use Horde\Core\Config\RegistryConfigLoader; use Horde\Core\Config\State; @@ -81,6 +82,7 @@ use Horde\Core\Factory\OAuthConsentMiddlewareFactory; use Horde\Core\Factory\OAuthFlowStoreFactory; use Horde\Core\Factory\PermissionServiceFactory; +use Horde\Core\Factory\PrefsConfigLoaderFactory; use Horde\Core\Factory\PrefsServiceFactory; use Horde\Core\Service\Factory\CredentialStoreFactory; use Horde\Core\Service\Factory\CredentialProvisioningStrategyFactory; @@ -290,6 +292,7 @@ public function register(Injector $injector): void AuthenticationService::class => AuthenticationServiceFactory::class, ConfigLoader::class => ConfigLoaderFactory::class, BackendConfigLoader::class => Factory\BackendConfigLoaderFactory::class, + PrefsConfigLoader::class => PrefsConfigLoaderFactory::class, MotdLoader::class => Factory\MotdLoaderFactory::class, State::class => StateFactory::class, DriverRepository::class => DriverRepositoryFactory::class, diff --git a/src/Factory/LdapPrefsServiceFactory.php b/src/Factory/LdapPrefsServiceFactory.php index af7c0d68..e9fbe0a6 100644 --- a/src/Factory/LdapPrefsServiceFactory.php +++ b/src/Factory/LdapPrefsServiceFactory.php @@ -16,9 +16,10 @@ namespace Horde\Core\Factory; -use Horde\Core\Service\LdapPrefsService; -use Horde\Core\Service\HordeLdapService; use Horde\Core\Config\ConfigLoader; +use Horde\Core\Config\PrefsConfigLoader; +use Horde\Core\Service\HordeLdapService; +use Horde\Core\Service\LdapPrefsService; use Horde\Injector\Injector; use RuntimeException; @@ -56,6 +57,7 @@ public function create(Injector $injector): LdapPrefsService return new LdapPrefsService( ldapService: $ldapService, + prefsConfigLoader: $injector->get(PrefsConfigLoader::class), basedn: $params['basedn'] ); } diff --git a/src/Factory/PrefsConfigLoaderFactory.php b/src/Factory/PrefsConfigLoaderFactory.php new file mode 100644 index 00000000..4412d324 --- /dev/null +++ b/src/Factory/PrefsConfigLoaderFactory.php @@ -0,0 +1,55 @@ +create('horde', 'prefs') - * - For now, we use default 'horde' SQL connection - * - Future: Extend DbServiceFactory to accept optional service parameter + * ldap: + * basedn - Base DN for user searches; required + * e.g. 'ou=people,dc=example,dc=com' + * The LDAP connection itself is sourced from HordeLdapService (configured + * separately under $conf['ldap'] or $conf['auth']['params']). * * @category Horde * @package Core @@ -100,9 +73,10 @@ public function create(Injector $injector): PrefsService $params = $state->get('prefs.params', []); return match (strtolower($driver)) { - 'sql' => $this->createSqlBackend($injector, $params), + 'sql' => $this->createSqlBackend($injector, $params), + 'ldap' => $this->createLdapBackend($injector, $params), 'null', 'session' => $this->createNullBackend(), - default => throw new RuntimeException("Unsupported prefs driver: $driver (TODO: implement)"), + default => throw new RuntimeException("Unsupported prefs driver: $driver"), }; } @@ -120,7 +94,39 @@ private function createSqlBackend(Injector $injector, array $params): SqlPrefsSe $table = $params['table'] ?? 'horde_prefs'; - return new SqlPrefsService($dbService->getAdapter(), $table); + return new SqlPrefsService( + $dbService->getAdapter(), + $injector->get(PrefsConfigLoader::class), + $table + ); + } + + /** + * Create LDAP prefs backend + * + * Requires $conf['prefs']['params']['basedn'] in conf.php. + * The LDAP connection is resolved from the container via HordeLdapService, + * configured under $conf['ldap'] (or $conf['auth']['params'] for auth-bound + * connections). See doc/PREFERENCES.md for details. + * + * @param Injector $injector Dependency injector + * @param array $params Prefs configuration parameters + * @return LdapPrefsService LDAP prefs service + * @throws RuntimeException If basedn is missing from configuration + */ + private function createLdapBackend(Injector $injector, array $params): LdapPrefsService + { + if (empty($params['basedn'])) { + throw new RuntimeException( + "LDAP prefs require \$conf['prefs']['params']['basedn'] in conf.php" + ); + } + + return new LdapPrefsService( + ldapService: $injector->get(HordeLdapService::class), + prefsConfigLoader: $injector->get(PrefsConfigLoader::class), + basedn: $params['basedn'] + ); } /** diff --git a/src/Prefs/Storage/PrefsConfigLoaderStorage.php b/src/Prefs/Storage/PrefsConfigLoaderStorage.php new file mode 100644 index 00000000..dc17251e --- /dev/null +++ b/src/Prefs/Storage/PrefsConfigLoaderStorage.php @@ -0,0 +1,137 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Prefs\Storage; + +use Horde\Core\Config\PrefsConfigLoader; +use Horde_Prefs_Storage_Base; +use Throwable; +use Horde_Prefs_Scope; + +/** + * Horde_Prefs storage driver backed by PrefsConfigLoader + * + * Replaces Horde_Core_Prefs_Storage_Configuration as the config layer in + * Horde_Prefs driver stacks. Reads preference defaults and lock flags from + * the five-layer cascade (vendor -> deployment base -> prefs.d/* snippets -> + * prefs.local.php -> prefs-$vhost.php) without touching $GLOBALS['registry']. + * + * This is intentionally read-only: store() and remove() are no-ops because + * config files are never written at runtime. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PrefsConfigLoaderStorage extends Horde_Prefs_Storage_Base +{ + /** + * Pref names that carry a hook. Keyed by scope. + * + * Populated during get() so that callers (e.g. Horde_Core_Prefs_Storage_Hooks) + * can discover them without re-reading config files. + * + * @var array> + */ + public array $hooks = []; + + /** + * Pref types that are UI-only and carry no storable value. + * + * Public so that other parts of the config cascade (e.g. SqlPrefsService) + * can reuse the same authoritative list without duplicating it. + */ + public const UI_TYPES = ['link', 'prefslink', 'rawhtml', 'container', 'special']; + + /** + * Constructor + * + * @param string $uid User ID (passed through to parent) + * @param PrefsConfigLoader $loader Config loader supplying the cascade + */ + public function __construct(string $uid, private PrefsConfigLoader $loader) + { + // Parent stores $uid as $this->_user and $params as $this->_params + parent::__construct($uid, []); + } + + /** + * Load preference defaults and lock flags from config files into $scope_ob. + * + * Applied before the SQL driver so that SQL user values win for unlocked + * prefs; locked prefs have Horde_Prefs_Scope::setLocked(true) set here and + * the subsequent SQL driver is instructed by Horde_Prefs to skip them. + * + * If the config files cannot be loaded (missing app, IO error, parse error) + * $scope_ob is returned unchanged. Same fallback as in the legacy Horde_Core_Prefs_Storage_Configuration. + * + * @param Horde_Prefs_Scope $scope_ob Scope object to populate + * @return Horde_Prefs_Scope Populated (or unchanged on error) scope object + */ + public function get($scope_ob) + { + try { + $state = $this->loader->load($scope_ob->scope); + } catch (Throwable) { + // Config files missing or unreadable. Degrade gracefully. + return $scope_ob; + } + + foreach ($state->getAllPrefs() as $name => $pref) { + // Skip UI-only entries that hold no storable value + if (!isset($pref['value'])) { + continue; + } + if (isset($pref['type']) && in_array($pref['type'], self::UI_TYPES, true)) { + continue; + } + + $scope_ob->set($name, $pref['value']); + + if (!empty($pref['locked'])) { + $scope_ob->setLocked($name, true); + } + + if (!empty($pref['hook'])) { + $this->hooks[$scope_ob->scope][] = $name; + } + } + + return $scope_ob; + } + + /** + * No-op: config files are read-only at runtime. + * + * @param Horde_Prefs_Scope $scope_ob Ignored + */ + public function store($scope_ob): void + { + // Config files are never written at runtime. + } + + /** + * No-op: config files are read-only at runtime. + * + * @param string|null $scope Ignored + * @param string|null $pref Ignored + */ + public function remove($scope = null, $pref = null): void + { + // Config files are never written at runtime. + } +} diff --git a/src/Service/AcquisitionAction.php b/src/Service/AcquisitionAction.php index 9df58b12..a155fd64 100644 --- a/src/Service/AcquisitionAction.php +++ b/src/Service/AcquisitionAction.php @@ -1,4 +1,5 @@ * Example: hordePrefHordeTheme stores the 'theme' preference in 'horde' scope. * + * ## Cascade order + * + * The five-layer config file cascade (via PrefsConfigCascadeTrait) is applied + * on top of the LDAP user attributes: + * + * 1. Config defaults seed the result (all non-UI prefs with a 'value'). + * 2. Locked prefs keep their config value regardless of any LDAP attribute. + * 3. Unlocked prefs: LDAP attribute value overwrites the config default. + * 4. LDAP-only prefs (no config definition) are included as-is. + * * @category Horde * @package Core * @author Ralf Lang @@ -37,75 +48,103 @@ */ class LdapPrefsService implements PrefsService { + use PrefsConfigCascadeTrait; + /** * Constructor * - * @param HordeLdapService $ldapService LDAP service for connections - * @param string $basedn Base DN for user searches + * @param HordeLdapService $ldapService LDAP service for connections + * @param PrefsConfigLoader $prefsConfigLoader Config cascade loader + * @param string $basedn Base DN for user searches */ public function __construct( private HordeLdapService $ldapService, + private PrefsConfigLoader $prefsConfigLoader, private string $basedn ) {} /** * Get preference value * - * @param string $uid User ID + * Returns the effective value applying the full cascade: + * - Locked prefs always return the config-layer value; LDAP is never + * consulted. + * - Unlocked prefs: LDAP attribute value if present, else config default. + * - Config default (or null) when the user has no LDAP entry. + * + * @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 + * @param string $key Preference key + * @return mixed Preference value, config default, or null + * @throws RuntimeException If a non-recoverable LDAP error occurs */ - public function getValue(string $uid, string $scope, string $key): ?string + public function getValue(string $uid, string $scope, string $key): mixed { + // Locked prefs always use the config-layer value. + if ($this->isLocked($uid, $scope, $key)) { + return $this->configDefault($scope, $key); + } + try { - $ldap = $this->ldapService->getAdapter(); + $ldap = $this->ldapService->getAdapter(); $userDN = $this->findUserDN($ldap, $uid); if (!$userDN) { - return null; + return $this->configDefault($scope, $key); } // Search for hordePerson entry - $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); $attrName = $this->buildAttributeName($scope, $key); $search = $ldap->search($userDN, $filter, [ 'attributes' => [$attrName], - 'scope' => 'sub', + 'scope' => 'sub', ]); if ($search->count() === 0) { - // No hordePerson entry, check user entry directly + // No hordePerson entry — check user entry directly try { $entry = $ldap->getEntry($userDN, ['attributes' => [$attrName]]); - return $entry->getValue($attrName, 'single'); + $value = $entry->getValue($attrName, 'single'); } catch (Horde_Ldap_Exception $e) { - return null; + $value = null; } + } else { + $entry = $search->shiftEntry(); + $value = $entry->getValue($attrName, 'single'); } - $entry = $search->shiftEntry(); - return $entry->getValue($attrName, 'single'); + // Fall back to config default when LDAP has no stored value + return $value ?? $this->configDefault($scope, $key); } catch (Horde_Ldap_Exception $e) { - throw new RuntimeException("Failed to get LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $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 + * Refuses to write a preference that is locked in the config cascade. + * Throws RuntimeException — same contract as SqlPrefsService — so callers + * can rely on a consistent lock-enforcement contract regardless of backend. + * + * @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 + * @param string $key Preference key + * @param mixed $value Preference value + * @throws RuntimeException If the preference is locked in config, or on LDAP error */ public function setValue(string $uid, string $scope, string $key, $value): void { + $this->assertNotLocked($scope, $key); + try { - $ldap = $this->ldapService->getAdapter(); + $ldap = $this->ldapService->getAdapter(); $userDN = $this->findUserDN($ldap, $uid); if (!$userDN) { @@ -116,13 +155,12 @@ public function setValue(string $uid, string $scope, string $key, $value): void $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']); - + $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 + // No hordePerson entry — modify user entry directly $entry = $ldap->getEntry($userDN); // Add hordePerson objectClass if not present. @@ -147,22 +185,26 @@ public function setValue(string $uid, string $scope, string $key, $value): void $entry->update(); } } catch (Horde_Ldap_Exception $e) { - throw new RuntimeException("Failed to set LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $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 $uid User ID * @param string $scope Preference scope - * @param string $key Preference key + * @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(); + $ldap = $this->ldapService->getAdapter(); $userDN = $this->findUserDN($ldap, $uid); if (!$userDN) { @@ -170,9 +212,8 @@ public function deleteValue(string $uid, string $scope, string $key): void } // Try to find hordePerson entry - $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); - $search = $ldap->search($userDN, $filter, ['scope' => 'sub']); - + $filter = Horde_Ldap_Filter::create('objectClass', 'equals', 'hordePerson'); + $search = $ldap->search($userDN, $filter, ['scope' => 'sub']); $attrName = $this->buildAttributeName($scope, $key); if ($search->count() > 0) { @@ -190,69 +231,90 @@ public function deleteValue(string $uid, string $scope, string $key): void } } } catch (Horde_Ldap_Exception $e) { - throw new RuntimeException("Failed to delete LDAP preference '$scope:$key' for user '$uid': " . $e->getMessage(), 0, $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 + * Returns the merged view applying the full cascade: + * + * 1. Config defaults seed the result (all storable, non-UI prefs with a + * 'value' in config). + * 2. Locked prefs keep their config value; any matching LDAP attribute is + * silently ignored. + * 3. Unlocked prefs: LDAP attribute value overlays the config default. + * 4. LDAP-only prefs (no config definition) are included as-is. + * + * If LDAP is unavailable, config defaults are returned without rethrowing. + * + * @param string $uid User ID * @param string $scope Preference scope - * @return array Associative array of key => value - * @throws RuntimeException If LDAP error occurs + * @return array Associative array of key => effective value */ public function getAllInScope(string $uid, string $scope): array { + // --- Step 1: seed from config defaults, record locked keys --- + ['defaults' => $result, 'locked' => $locked] = $this->buildConfigSeed($scope); + try { - $ldap = $this->ldapService->getAdapter(); + $ldap = $this->ldapService->getAdapter(); $userDN = $this->findUserDN($ldap, $uid); if (!$userDN) { - return []; + return $result; // Config defaults only } - // Search for hordePerson entry with all attributes + // --- Step 2: find the user's hordePerson entry --- $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 []; + return $result; // Config defaults only } } - // Extract preferences matching the scope prefix + // --- Step 3: overlay LDAP values, honouring locks --- + $prefix = 'hordePref' . $scope; + $prefixLen = strlen($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; + $key = lcfirst(substr($attrName, $prefixLen)); + if (isset($locked[$key])) { + // Config lock wins — skip this LDAP attribute + continue; + } + $result[$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); + // LDAP unavailable — return config defaults only (no rethrow) } + + return $result; } /** * Check if preference exists * - * @param string $uid User ID + * Returns true if the preference exists in the config cascade or has a + * stored LDAP attribute for the user. Delegates to getValue() which + * applies the full config+LDAP cascade, including config-only defaults. + * + * @param string $uid User ID * @param string $scope App name - * @param string $key Preference key + * @param string $key Preference key * @return bool True if preference exists */ public function exists(string $uid, string $scope, string $key): bool @@ -269,7 +331,7 @@ public function exists(string $uid, string $scope, string $key): bool * Find user DN by username * * @param Horde_Ldap $ldap LDAP connection - * @param string $uid User ID + * @param string $uid User ID * @return string|null User DN or null if not found */ private function findUserDN(Horde_Ldap $ldap, string $uid): ?string @@ -289,14 +351,14 @@ private function findUserDN(Horde_Ldap $ldap, string $uid): ?string * Example: hordePrefHordeTheme for scope='horde', key='theme' * * @param string $scope Preference scope - * @param string $key Preference key + * @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); + $key = ucfirst($key); return "hordePref{$scope}{$key}"; } diff --git a/src/Service/NullCredentialProvisioningStrategy.php b/src/Service/NullCredentialProvisioningStrategy.php index 8f518d26..b53cd73a 100644 --- a/src/Service/NullCredentialProvisioningStrategy.php +++ b/src/Service/NullCredentialProvisioningStrategy.php @@ -1,4 +1,5 @@ storage[$uid][$scope][$key]); } + /** + * Check if preference is locked + * + * A locked preference cannot be changed by the user. + * This implementation always returns false. + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool True if the preference is locked + */ + public function isLocked(string $uid, string $scope, string $key): bool + { + return false; + } } diff --git a/src/Service/NullServiceAuthorizationService.php b/src/Service/NullServiceAuthorizationService.php index 674ee08b..4dc7ea17 100644 --- a/src/Service/NullServiceAuthorizationService.php +++ b/src/Service/NullServiceAuthorizationService.php @@ -7,6 +7,7 @@ use Horde\OAuth\Client\OAuthFlowData; use Horde\OAuth\Client\ScopeSet; use Psr\Http\Message\UriInterface; +use RuntimeException; /** Null ServiceAuthorizationService - safe default when service authorization is not configured. */ class NullServiceAuthorizationService implements ServiceAuthorizationService @@ -36,7 +37,7 @@ public function initiate( public function handleCallback(string $code, OAuthFlowData $flowData): ServiceAuthorization { - throw new \RuntimeException('Service authorization not configured'); + throw new RuntimeException('Service authorization not configured'); } public function revoke( diff --git a/src/Service/NullServiceCredentialManager.php b/src/Service/NullServiceCredentialManager.php index 4515e117..52930835 100644 --- a/src/Service/NullServiceCredentialManager.php +++ b/src/Service/NullServiceCredentialManager.php @@ -1,4 +1,5 @@ + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Service; + +use Horde\Core\Prefs\Storage\PrefsConfigLoaderStorage; +use RuntimeException; + +/** + * Shared five-layer config cascade for PrefsService implementations. + * + * Provides the config-file cascade (vendor → deployment base → prefs.d/* + * snippets → local → vhost) as reusable building blocks for any storage + * backend. + * + * ## Requirements + * + * The consuming class MUST declare: + * + * private Horde\Core\Config\PrefsConfigLoader $prefsConfigLoader; + * + * ## Provided methods + * + * Public (fulfils PrefsService interface contract): + * isLocked(string $uid, string $scope, string $key): bool + * + * Protected helpers for consuming class: + * assertNotLocked(string $scope, string $key): void + * buildConfigSeed(string $scope): array + * configDefault(string $scope, string $key): mixed + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +trait PrefsConfigCascadeTrait +{ + /** + * True if the pref is locked by a config layer. + * + * Lock state is entirely deployment-wide; $uid is accepted only to + * satisfy the PrefsService interface contract and has no effect. + * + * @param string $uid User ID (lock state is not per-user so this is ignored) + * @param string $scope App name + * @param string $key Preference key + * @return bool True if the pref is locked in config + */ + public function isLocked(string $uid, string $scope, string $key): bool + { + $pref = $this->prefsConfigLoader->load($scope)->getPref($key); + return $pref !== null && !empty($pref['locked']); + } + + /** + * Throw RuntimeException if the pref is locked in config. + * + * Call at the very start of setValue() before touching the storage + * backend to prevent stale locked rows from accumulating. + * + * @param string $scope App name + * @param string $key Preference key + * @throws RuntimeException If the pref is locked in config + */ + protected function assertNotLocked(string $scope, string $key): void + { + $pref = $this->prefsConfigLoader->load($scope)->getPref($key); + if ($pref !== null && !empty($pref['locked'])) { + throw new RuntimeException( + "Preference '$key' in scope '$scope' is locked by configuration and cannot be modified." + ); + } + } + + /** + * Build the config-defaults seed for getAllInScope(). + * + * Iterates every pref in the merged five-layer config state and returns: + * + * 'defaults' key => value for every storable, non-UI-only pref that + * carries a 'value' key in config. + * 'locked' set of keys (key => true) locked in config. + * + * UI-only types (link, prefslink, rawhtml, container, special) and prefs + * with no 'value' are excluded from 'defaults'. + * + * The result is intended as the initial result array for getAllInScope(). + * The backend implementation then overlays user-specific rows while + * honouring the 'locked' set. + * + * @param string $scope App name + * @return array{defaults: array, locked: array} + */ + protected function buildConfigSeed(string $scope): array + { + $state = $this->prefsConfigLoader->load($scope); + $defaults = []; + $locked = []; + + foreach ($state->getAllPrefs() as $name => $pref) { + if (isset($pref['type']) && in_array($pref['type'], PrefsConfigLoaderStorage::UI_TYPES, true)) { + continue; + } + if (!isset($pref['value'])) { + continue; + } + $defaults[$name] = $pref['value']; + if (!empty($pref['locked'])) { + $locked[$name] = true; + } + } + + return ['defaults' => $defaults, 'locked' => $locked]; + } + + /** + * Return the config-layer default for a single pref, or null. + * + * Returns null when: + * - The pref is not defined in config. + * - The config definition has no 'value' key. + * - The pref type is UI-only (link, prefslink, rawhtml, container, special). + * + * Use as the fallback in getValue() when the storage backend returns null, + * and as the definitive value for locked prefs (skip the backend entirely). + * + * @param string $scope App name + * @param string $key Preference key + * @return mixed Config-layer default or null + */ + protected function configDefault(string $scope, string $key): mixed + { + $pref = $this->prefsConfigLoader->load($scope)->getPref($key); + if ($pref === null || !isset($pref['value'])) { + return null; + } + if (isset($pref['type']) && in_array($pref['type'], PrefsConfigLoaderStorage::UI_TYPES, true)) { + return null; + } + return $pref['value']; + } +} diff --git a/src/Service/PrefsService.php b/src/Service/PrefsService.php index 60172e7a..3d8b0547 100644 --- a/src/Service/PrefsService.php +++ b/src/Service/PrefsService.php @@ -25,7 +25,8 @@ * 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. + * Schema and defaults are out of scope. Locking is determined by the + * configuration driver (PrefsConfigLoaderStorage, backed by PrefsConfigLoader). * * @category Horde * @package Core @@ -84,4 +85,18 @@ public function getAllInScope(string $uid, string $scope): array; * @return bool True if preference exists */ public function exists(string $uid, string $scope, string $key): bool; + + /** + * Check if preference is locked + * + * A locked preference cannot be changed by the user. + * Lock state is determined by the configuration driver + * (PrefsConfigLoaderStorage / PrefsConfigLoader), not by per-user storage. + * + * @param string $uid User ID + * @param string $scope App name + * @param string $key Preference key + * @return bool True if the preference is locked + */ + public function isLocked(string $uid, string $scope, string $key): bool; } diff --git a/src/Service/ProvisioningAction.php b/src/Service/ProvisioningAction.php index 6ddb6bda..c1913403 100644 --- a/src/Service/ProvisioningAction.php +++ b/src/Service/ProvisioningAction.php @@ -1,4 +1,5 @@ base -> prefs.d/* -> local -> + * vhost) without touching $GLOBALS['registry']. Horde_Prefs_Storage_Sql + * (user layer) then overwrites unlocked keys with per-user DB values. + * + * getValue(), exists(), and isLocked() all go through createPrefs() and + * therefore see the full cascade correctly. + * + * getAllInScope() builds the same merged view without instantiating + * Horde_Prefs: it starts from the config defaults returned by + * PrefsConfigLoader, overlays DB rows for non-locked keys, and returns + * the result. This ensures config-only defaults appear in the output + * and locked prefs are never shadowed by stale DB rows. + * + * setValue() checks the config lock flag via PrefsConfigLoader before + * writing, preventing locked prefs from accumulating stale DB rows. + * * @category Horde * @package Core * @author Ralf Lang @@ -37,30 +62,34 @@ */ class SqlPrefsService implements PrefsService { + use PrefsConfigCascadeTrait; /** * Constructor * - * @param Horde_Db_Adapter $db Database adapter - * @param string $table Prefs table name (default: 'horde_prefs') + * @param Horde_Db_Adapter $db Database adapter + * @param PrefsConfigLoader $prefsConfigLoader Config cascade loader + * @param string $table Prefs table name */ public function __construct( private Horde_Db_Adapter $db, + private PrefsConfigLoader $prefsConfigLoader, private string $table = 'horde_prefs' ) {} /** * Get preference value * - * @param string $uid User ID + * Delegates to Horde_Prefs which applies the full config+SQL cascade. + * + * @param string $uid User ID * @param string $scope App name - * @param string $key Preference key + * @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; } @@ -71,84 +100,103 @@ public function getValue(string $uid, string $scope, string $key) /** * Set preference value * - * @param string $uid User ID + * Writes $value to the SQL backend for the given user/scope/key. + * Throws RuntimeException if the preference is locked in the config + * cascade. Writing a locked pref would create a stale DB row that + * could resurface if the lock is later removed. + * + * @param string $uid User ID * @param string $scope App name - * @param string $key Preference key - * @param mixed $value Preference value - * @return void + * @param string $key Preference key + * @param mixed $value Preference value + * @throws RuntimeException If the preference is locked in config */ public function setValue(string $uid, string $scope, string $key, $value): void { - // Create storage backend directly to bypass Horde_Prefs existence checks + $this->assertNotLocked($scope, $key); + $storage = new Horde_Prefs_Storage_Sql($uid, [ - 'db' => $this->db, + '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 $uid User ID * @param string $scope App name - * @param string $key Preference key - * @return void + * @param string $key Preference key */ 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, + '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 + * Returns the merged view that mirrors what getValue() produces for each + * individual key: + * + * 1. Config defaults. All non-UI prefs with a 'value' defined in the + * five-layer file cascade are seeded into the result. + * 2. Locked prefs. Kept at their config value; any matching DB row is + * silently ignored (same as Horde_Prefs::getValue() behaviour). + * 3. Unlocked prefs. DB value overlays the config default when present. + * 4. DB-only prefs. Prefs that exist in the DB but have no config + * definition are included as-is (user-defined custom prefs). + * + * Unlike the previous raw-SQL implementation, config-only defaults (prefs + * with no user DB row) now appear in the output. + * + * @param string $uid User ID * @param string $scope App name - * @return array Associative array of key => value + * @return array key => effective 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]); + // --- Step 1: seed from config defaults --- + ['defaults' => $result, 'locked' => $locked] = $this->buildConfigSeed($scope); - $prefs = []; + // --- Step 2: overlay DB values, honouring locks --- + try { + $query = 'SELECT pref_name, pref_value FROM ' . $this->table + . ' WHERE pref_uid = ? AND pref_scope = ?'; + $rows = $this->db->select($query, [$uid, $scope]); $columns = $this->db->columns($this->table); - foreach ($result as $row) { + foreach ($rows as $row) { $key = trim($row['pref_name']); - $value = $columns['pref_value']->binaryToString($row['pref_value']); - $prefs[$key] = $value; + if (isset($locked[$key])) { + // Config lock wins. Do not let a stale DB row shadow it + continue; + } + $result[$key] = $columns['pref_value']->binaryToString($row['pref_value']); } - - return $prefs; - } catch (Horde_Db_Exception $e) { - // Return empty array on error - return []; + } catch (Horde_Db_Exception) { + // DB unavailable: return config defaults only } + + return $result; } /** * Check if preference exists * - * @param string $uid User ID + * @param string $uid User ID * @param string $scope App name - * @param string $key Preference key + * @param string $key Preference key * @return bool True if preference exists */ public function exists(string $uid, string $scope, string $key): bool @@ -160,24 +208,30 @@ public function exists(string $uid, string $scope, string $key): bool /** * Create Horde_Prefs instance for user/scope * - * @param string $uid User ID + * Driver stack: + * [PrefsConfigLoaderStorage, Horde_Prefs_Storage_Sql] + * + * PrefsConfigLoaderStorage reads defaults and locked flags from the + * five-layer config file cascade via PrefsConfigLoader — no + * $GLOBALS['registry'] required. Horde_Prefs_Storage_Sql then + * overlays per-user DB values for unlocked prefs. + * + * @param string $uid User ID * @param string $scope App name - * @return Horde_Prefs Prefs instance + * @return Horde_Prefs Cascaded prefs instance */ private function createPrefs(string $uid, string $scope): Horde_Prefs { - $configDriver = new Horde_Core_Prefs_Storage_Configuration($uid); + $configDriver = new PrefsConfigLoaderStorage($uid, $this->prefsConfigLoader); $sqlDriver = new Horde_Prefs_Storage_Sql($uid, [ - 'db' => $this->db, + 'db' => $this->db, 'table' => $this->table, ]); - return new Horde_Prefs($scope, [ - $configDriver, - $sqlDriver, - ], [ + return new Horde_Prefs($scope, [$configDriver, $sqlDriver], [ 'user' => $uid, ]); } + } diff --git a/src/Service/TokenGrant.php b/src/Service/TokenGrant.php index 3d1558fe..04d8bf9a 100644 --- a/src/Service/TokenGrant.php +++ b/src/Service/TokenGrant.php @@ -1,4 +1,5 @@ ldapService = $this->createStub(HordeLdapService::class); + + // Empty config dir — no prefs.php files means no locked prefs + $this->tempDir = sys_get_temp_dir() . '/ldap_prefs_test_' . uniqid(); + mkdir($this->tempDir . '/vendor/horde/horde/config', 0o755, true); + mkdir($this->tempDir . '/config/horde', 0o755, true); + $this->configLoader = new PrefsConfigLoader( + $this->tempDir . '/config', + $this->tempDir . '/vendor/horde' + ); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (array_diff(scandir($dir), ['.', '..']) as $entry) { + $path = $dir . '/' . $entry; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + /** + * Write vendor-layer prefs.php for 'horde' scope. + * + * @param array> $prefs + */ + private function writeVendorPrefs(array $prefs): void + { + $lines = [' $def) { + $exported = var_export($def, true); + $lines[] = "\$_prefs['" . addslashes($name) . "'] = $exported;"; + } + file_put_contents( + $this->tempDir . '/vendor/horde/horde/config/prefs.php', + implode("\n", $lines) + ); + // Config loader caches; create a fresh instance after writing. + $this->configLoader = new PrefsConfigLoader( + $this->tempDir . '/config', + $this->tempDir . '/vendor/horde' + ); } + /** + * Build LDAP stubs where findUserDN finds a user but no hordePerson entry + * and no matching attributes exist. + * + * Useful for testing config-default-only paths where LDAP has nothing. + * + * @return HordeLdapService + */ + private function mockLdapReturningNothing(): HordeLdapService + { + $ldapAdapter = $this->createStub(Horde_Ldap::class); + $ldapAdapter->method('findUserDN') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createStub(Horde_Ldap_Search::class); + $search->method('count')->willReturn(0); + $ldapAdapter->method('search')->willReturn($search); + + // getEntry throws — no attribute on the user entry either + $ldapAdapter->method('getEntry') + ->willThrowException(new Horde_Ldap_Exception('No such attribute')); + + $svc = $this->createStub(HordeLdapService::class); + $svc->method('getAdapter')->willReturn($ldapAdapter); + return $svc; + } + + // ------------------------------------------------------------------- + // LDAP-only tests (no config cascade) + // ------------------------------------------------------------------- + public function testGetValueFromHordePerson(): void { $ldapAdapter = $this->createStub(Horde_Ldap::class); @@ -60,7 +145,7 @@ public function testGetValueFromHordePerson(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $value = $service->getValue('alice', 'horde', 'theme'); @@ -81,7 +166,7 @@ public function testGetValueFromUserEntry(): void $ldapAdapter->method('getEntry')->willReturn($entry); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $value = $service->getValue('alice', 'horde', 'theme'); @@ -95,7 +180,7 @@ public function testGetValueNotFound(): void ->willThrowException(new Horde_Ldap_Exception('User not found')); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $value = $service->getValue('nonexistent', 'horde', 'theme'); @@ -121,7 +206,7 @@ public function testSetValueNewHordePerson(): void $ldapAdapter->method('getEntry')->willReturn($entry); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $service->setValue('alice', 'horde', 'theme', 'silver'); } @@ -144,7 +229,7 @@ public function testSetValueExistingHordePerson(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $service->setValue('alice', 'horde', 'theme', 'silver'); } @@ -168,7 +253,7 @@ public function testDeleteValue(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $service->deleteValue('alice', 'horde', 'theme'); } @@ -180,7 +265,7 @@ public function testDeleteValueUserNotFound(): void ->willThrowException(new Horde_Ldap_Exception('User not found')); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); // Should not throw exception $service->deleteValue('nonexistent', 'horde', 'theme'); @@ -212,7 +297,7 @@ public function testGetAllInScope(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $prefs = $service->getAllInScope('alice', 'horde'); @@ -245,7 +330,7 @@ public function testGetAllInScopeEmpty(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $prefs = $service->getAllInScope('alice', 'horde'); @@ -272,7 +357,7 @@ public function testExistsTrue(): void $ldapAdapter->method('search')->willReturn($search); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $this->assertTrue($service->exists('alice', 'horde', 'theme')); } @@ -284,7 +369,7 @@ public function testExistsFalse(): void ->willThrowException(new Horde_Ldap_Exception('User not found')); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $this->assertFalse($service->exists('nonexistent', 'horde', 'theme')); } @@ -316,7 +401,7 @@ public function testAttributeNaming(): void $ldapAdapter->method('getEntry')->willReturn($entry); $this->ldapService->method('getAdapter')->willReturn($ldapAdapter); - $service = new LdapPrefsService($this->ldapService, 'ou=users,dc=example,dc=com'); + $service = new LdapPrefsService($this->ldapService, $this->configLoader, 'ou=users,dc=example,dc=com'); $service->setValue('alice', 'imp', 'sentFolder', '/Sent'); @@ -327,4 +412,327 @@ public function testAttributeNaming(): void $this->assertArrayHasKey('hordePrefImpSentFolder', $replaceCalls[1]); $this->assertEquals('/Sent', $replaceCalls[1]['hordePrefImpSentFolder']); } + + public function testIsLockedReturnsFalseWithNoConfig(): void + { + // Empty config dir — no prefs.php — so nothing is locked + $service = new LdapPrefsService( + $this->ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertFalse($service->isLocked('alice', 'horde', 'theme')); + } + + public function testIsLockedReturnsTrueWhenConfigLockSet(): void + { + // Write a vendor prefs.php that locks 'theme' + file_put_contents( + $this->tempDir . '/vendor/horde/horde/config/prefs.php', + ' \'corporate\', \'locked\' => true];' + ); + // Reload — cache must be clear because we just created the file + $loader = new PrefsConfigLoader( + $this->tempDir . '/config', + $this->tempDir . '/vendor/horde' + ); + $service = new LdapPrefsService($this->ldapService, $loader, 'ou=users,dc=example,dc=com'); + + $this->assertTrue($service->isLocked('alice', 'horde', 'theme')); + } + + public function testSetValueThrowsForLockedPref(): void + { + file_put_contents( + $this->tempDir . '/vendor/horde/horde/config/prefs.php', + ' \'corporate\', \'locked\' => true];' + ); + $loader = new PrefsConfigLoader( + $this->tempDir . '/config', + $this->tempDir . '/vendor/horde' + ); + // LDAP adapter must never be called when the lock check fires first + $this->ldapService->expects($this->never())->method('getAdapter'); + + $service = new LdapPrefsService($this->ldapService, $loader, 'ou=users,dc=example,dc=com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/locked/i'); + + $service->setValue('alice', 'horde', 'theme', 'custom'); + } + + // ------------------------------------------------------------------- + // Config cascade tests (PrefsConfigCascadeTrait integration) + // ------------------------------------------------------------------- + + /** + * getValue() must return the config default when the user has no LDAP + * attribute for the requested pref. + */ + public function testGetValueReturnsConfigDefaultWhenNoLdapAttribute(): void + { + $this->writeVendorPrefs([ + 'language' => ['value' => 'de_DE', 'type' => 'select'], + ]); + + $service = new LdapPrefsService( + $this->mockLdapReturningNothing(), + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertSame('de_DE', $service->getValue('alice', 'horde', 'language')); + } + + /** + * getValue() must return the config value for a locked pref even when + * LDAP would provide a different value. + */ + public function testGetValueReturnsConfigValueForLockedPref(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'corporate', 'type' => 'select', 'locked' => true], + ]); + + // LDAP adapter should never be consulted for a locked pref + $ldapService = $this->createStub(HordeLdapService::class); + $ldapService->expects($this->never())->method('getAdapter'); + + $service = new LdapPrefsService( + $ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertSame( + 'corporate', + $service->getValue('alice', 'horde', 'theme'), + 'Locked pref must return config value without consulting LDAP' + ); + } + + /** + * getValue() must return the LDAP value (not the config default) for an + * unlocked pref when an LDAP attribute exists. + */ + public function testGetValueLdapOverridesConfigDefault(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + ]); + + $ldapAdapter = $this->createStub(Horde_Ldap::class); + $ldapAdapter->method('findUserDN') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->createStub(Horde_Ldap_Search::class); + $search->method('count')->willReturn(1); + + $entry = $this->createStub(Horde_Ldap_Entry::class); + $entry->method('getValue')->willReturn('gold'); + $search->method('shiftEntry')->willReturn($entry); + $ldapAdapter->method('search')->willReturn($search); + + $ldapService = $this->createStub(HordeLdapService::class); + $ldapService->method('getAdapter')->willReturn($ldapAdapter); + + $service = new LdapPrefsService( + $ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertSame('gold', $service->getValue('alice', 'horde', 'theme')); + } + + /** + * getAllInScope() must return config defaults even when the user has no + * LDAP attributes for the requested scope. + */ + public function testGetAllInScopeReturnsConfigDefaults(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + 'language' => ['value' => 'en_US', 'type' => 'select'], + ]); + + $service = new LdapPrefsService( + $this->mockLdapReturningNothing(), + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $all = $service->getAllInScope('alice', 'horde'); + + $this->assertSame('silver', $all['theme']); + $this->assertSame('en_US', $all['language']); + } + + /** + * getAllInScope() must overlay LDAP values on config defaults for unlocked + * prefs. + */ + public function testGetAllInScopeLdapOverridesConfigDefault(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + ]); + + $ldapAdapter = $this->createStub(Horde_Ldap::class); + $ldapAdapter->method('findUserDN') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->getMockBuilder(Horde_Ldap_Search::class) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->getMock(); + $search->method('count')->willReturn(1); + + $entry = $this->getMockBuilder(Horde_Ldap_Entry::class) + ->disableOriginalConstructor() + ->getMock(); + $entry->method('getValues')->willReturn([ + 'hordePrefhordeTheme' => ['gold'], + ]); + $search->method('shiftEntry')->willReturn($entry); + $ldapAdapter->method('search')->willReturn($search); + + $ldapService = $this->createStub(HordeLdapService::class); + $ldapService->method('getAdapter')->willReturn($ldapAdapter); + + $service = new LdapPrefsService( + $ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $all = $service->getAllInScope('alice', 'horde'); + + $this->assertSame( + 'gold', + $all['theme'], + 'Unlocked pref: LDAP value must overlay config default' + ); + } + + /** + * getAllInScope() must keep the config value for locked prefs even when + * an LDAP attribute carries a different value. + */ + public function testGetAllInScopeLockPreventsLdapOverride(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'corporate', 'type' => 'select', 'locked' => true], + ]); + + $ldapAdapter = $this->createStub(Horde_Ldap::class); + $ldapAdapter->method('findUserDN') + ->willReturn('uid=alice,ou=users,dc=example,dc=com'); + + $search = $this->getMockBuilder(Horde_Ldap_Search::class) + ->disableOriginalConstructor() + ->disableOriginalClone() + ->getMock(); + $search->method('count')->willReturn(1); + + $entry = $this->getMockBuilder(Horde_Ldap_Entry::class) + ->disableOriginalConstructor() + ->getMock(); + $entry->method('getValues')->willReturn([ + 'hordePrefhordeTheme' => ['gold'], // stale LDAP attribute + ]); + $search->method('shiftEntry')->willReturn($entry); + $ldapAdapter->method('search')->willReturn($search); + + $ldapService = $this->createStub(HordeLdapService::class); + $ldapService->method('getAdapter')->willReturn($ldapAdapter); + + $service = new LdapPrefsService( + $ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $all = $service->getAllInScope('alice', 'horde'); + + $this->assertSame( + 'corporate', + $all['theme'], + 'Locked pref: config value must win over LDAP attribute' + ); + } + + /** + * getAllInScope() must exclude UI-only pref types from the result. + */ + public function testGetAllInScopeExcludesUiOnlyPrefs(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + 'display_header' => ['value' => '', 'type' => 'container'], + 'some_link' => ['value' => '', 'type' => 'link'], + ]); + + $service = new LdapPrefsService( + $this->mockLdapReturningNothing(), + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $all = $service->getAllInScope('alice', 'horde'); + + $this->assertArrayHasKey('theme', $all); + $this->assertArrayNotHasKey('display_header', $all); + $this->assertArrayNotHasKey('some_link', $all); + } + + /** + * exists() must return true for a pref that is defined in config even + * when the user has no LDAP attribute for it. + */ + public function testExistsReturnsTrueForConfigOnlyPref(): void + { + $this->writeVendorPrefs([ + 'language' => ['value' => 'de_DE', 'type' => 'select'], + ]); + + $service = new LdapPrefsService( + $this->mockLdapReturningNothing(), + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertTrue( + $service->exists('alice', 'horde', 'language'), + 'exists() must return true for a pref only defined in config' + ); + } + + /** + * getValue() for a user not found in LDAP must fall back to config + * default rather than returning null. + */ + public function testGetValueForMissingUserReturnsConfigDefault(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + ]); + + $ldapAdapter = $this->createStub(Horde_Ldap::class); + $ldapAdapter->method('findUserDN') + ->willThrowException(new Horde_Ldap_Exception('User not found')); + + $ldapService = $this->createStub(HordeLdapService::class); + $ldapService->method('getAdapter')->willReturn($ldapAdapter); + + $service = new LdapPrefsService( + $ldapService, + $this->configLoader, + 'ou=users,dc=example,dc=com' + ); + + $this->assertSame('silver', $service->getValue('nobody', 'horde', 'theme')); + } } diff --git a/test/Unit/Service/SqlPrefsServiceTest.php b/test/Unit/Service/SqlPrefsServiceTest.php new file mode 100644 index 00000000..c399b748 --- /dev/null +++ b/test/Unit/Service/SqlPrefsServiceTest.php @@ -0,0 +1,435 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Core\Test\Unit\Service; + +use Horde\Core\Config\PrefsConfigLoader; +use Horde\Core\Service\SqlPrefsService; +use Horde_Db_Exception; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Horde_Db_Adapter; +use Horde_Prefs; + +/** + * Tests for SqlPrefsService + * + * Covers three behaviours: + * 1. getAllInScope() merges config defaults with DB values and honours locks + * 2. setValue() throws RuntimeException when a pref is locked + * 3. createPrefs() uses PrefsConfigLoaderStorage (no $GLOBALS['registry'] dep) + * + * The suite uses a real PrefsConfigLoader backed by a temporary directory so + * that config-layer behaviour is exercised without touching the filesystem + * permanently. The Horde_Db_Adapter is mocked so no live DB is required. + * + * @category Horde + * @package Core + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversClass(SqlPrefsService::class)] +class SqlPrefsServiceTest extends TestCase +{ + // ----------------------------------------------------------------------- + // Infrastructure + // ----------------------------------------------------------------------- + + private string $tempDir; + private string $vendorDir; + private string $configDir; + + protected function setUp(): void + { + if (!class_exists(Horde_Prefs::class)) { + $this->markTestSkipped('Horde_Prefs not available. Install horde/prefs'); + } + + $this->tempDir = sys_get_temp_dir() . '/sql_prefs_test_' . uniqid(); + $this->vendorDir = $this->tempDir . '/vendor/horde'; + $this->configDir = $this->tempDir . '/config'; + + mkdir($this->vendorDir . '/horde/config', 0o755, true); + mkdir($this->configDir . '/horde', 0o755, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (array_diff(scandir($dir), ['.', '..']) as $entry) { + $path = $dir . '/' . $entry; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } + + /** + * Write vendor-layer prefs.php for 'horde' scope. + * + * @param array> $prefs + */ + private function writeVendorPrefs(array $prefs): void + { + $lines = [' $def) { + $exported = var_export($def, true); + $lines[] = "\$_prefs['" . addslashes($name) . "'] = $exported;"; + } + file_put_contents( + $this->vendorDir . '/horde/config/prefs.php', + implode("\n", $lines) + ); + } + + /** + * Build a thin column stand-in whose binaryToString() is a no-op. + * + * We deliberately use an anonymous class instead of + * createStub(\Horde_Db_Adapter_Base_Column::class) so that the test + * works even when the horde/db package is not installed (the legacy class + * may not be available in the autoloader during unit-test runs). + * + * @return object + */ + private function makePassthroughColumn(): object + { + return new class { + public function binaryToString($value) + { + return $value; + } + + public function stringToTyped($value) + { + return $value; + } + }; + } + + /** + * Build a mock Horde_Db_Adapter whose select() returns $rows. + * + * Each row must be ['pref_name' => string, 'pref_value' => string]. + * + * @param array> $rows + * @return Horde_Db_Adapter + */ + private function mockDbReturning(array $rows): Horde_Db_Adapter + { + $db = $this->createMock(Horde_Db_Adapter::class); + $db->method('select')->willReturn($rows); + $db->method('columns')->willReturn(['pref_value' => $this->makePassthroughColumn()]); + + return $db; + } + + /** + * Build a mock Horde_Db_Adapter that throws on select(). + */ + private function mockDbThrowingOnSelect(): Horde_Db_Adapter + { + $db = $this->createMock(Horde_Db_Adapter::class); + $db->method('select')->willThrowException(new Horde_Db_Exception('DB offline')); + return $db; + } + + /** + * Build a mock Horde_Db_Adapter that permits any read/write calls. + * + * INSERT/UPDATE are allowed (Horde_Prefs_Storage_Sql::store() calls one + * of them depending on whether the row already exists). We do not assert + * exactly which one is called. We only verify that no exception is thrown. + */ + private function mockDbExpectingWrite(): Horde_Db_Adapter + { + $db = $this->createMock(Horde_Db_Adapter::class); + $db->method('select')->willReturn([]); + $db->method('columns')->willReturn(['pref_value' => $this->makePassthroughColumn()]); + $db->method('insert')->willReturn(1); + $db->method('update')->willReturn(1); + + return $db; + } + + private function makeService(Horde_Db_Adapter $db, ?PrefsConfigLoader $loader = null): SqlPrefsService + { + $loader ??= new PrefsConfigLoader($this->configDir, $this->vendorDir); + return new SqlPrefsService($db, $loader); + } + + // ----------------------------------------------------------------------- + // getAllInScope() merges config defaults with DB values + // ----------------------------------------------------------------------- + + /** + * When a pref has a default in prefs.php and no DB row, getAllInScope() + * must return the config default. + */ + public function testGetAllInScopeReturnsConfigDefault(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + 'language' => ['value' => 'en_US', 'type' => 'select'], + ]); + + // DB has no rows for this user/scope + $service = $this->makeService($this->mockDbReturning([])); + + $all = $service->getAllInScope('alice', 'horde'); + + $this->assertSame('silver', $all['theme']); + $this->assertSame('en_US', $all['language']); + } + + /** + * A DB row for an unlocked pref must override the config default. + */ + public function testGetAllInScopeDbValueOverridesDefault(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + ]); + + $db = $this->mockDbReturning([ + ['pref_name' => 'theme', 'pref_value' => 'gold'], + ]); + + $all = $this->makeService($db)->getAllInScope('alice', 'horde'); + + $this->assertSame('gold', $all['theme']); + } + + /** + * A DB row for a locked pref must NOT override the config value. This is a new behaviour in H6. + * The config layer wins. The stale DB row is ignored. + */ + public function testGetAllInScopeLockPreventsDbOverride(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'corporate', 'type' => 'select', 'locked' => true], + ]); + + $db = $this->mockDbReturning([ + ['pref_name' => 'theme', 'pref_value' => 'gold'], // stale row + ]); + + $all = $this->makeService($db)->getAllInScope('alice', 'horde'); + + $this->assertSame( + 'corporate', + $all['theme'], + 'Locked pref: config value must win over stale DB row' + ); + } + + /** + * A DB row for a pref that has no config definition (e.g. deprecated or + * app-specific) must still appear in the result. + */ + public function testGetAllInScopeIncludesDbOnlyPrefs(): void + { + // Empty config + $db = $this->mockDbReturning([ + ['pref_name' => 'custom_flag', 'pref_value' => '1'], + ]); + + $all = $this->makeService($db)->getAllInScope('alice', 'horde'); + + $this->assertArrayHasKey('custom_flag', $all); + $this->assertSame('1', $all['custom_flag']); + } + + /** + * UI-only pref types (link, prefslink, rawhtml, container, special) must + * not appear in getAllInScope() results even if they have a 'value' key. + */ + public function testGetAllInScopeExcludesUiOnlyPrefs(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + 'display_header' => ['value' => '', 'type' => 'container'], + 'some_link' => ['value' => '', 'type' => 'link'], + ]); + + $all = $this->makeService($this->mockDbReturning([]))->getAllInScope('alice', 'horde'); + + $this->assertArrayHasKey('theme', $all); + $this->assertArrayNotHasKey('display_header', $all); + $this->assertArrayNotHasKey('some_link', $all); + } + + /** + * When the DB raises an exception getAllInScope() must gracefully fall + * back to config defaults only (no rethrow). + */ + public function testGetAllInScopeFallsBackToConfigOnDbError(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + ]); + + $all = $this->makeService($this->mockDbThrowingOnSelect())->getAllInScope('alice', 'horde'); + + $this->assertSame( + 'silver', + $all['theme'], + 'Config default should still be returned when DB is unavailable' + ); + } + + /** + * getAllInScope() used to return an empty array when there were no DB rows, + * silently hiding every config default. Verify the pre-fix regression is gone. + */ + public function testGetAllInScopeIsNotEmptyWhenNoDbRows(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], + 'language' => ['value' => 'en_US', 'type' => 'select'], + ]); + + $all = $this->makeService($this->mockDbReturning([]))->getAllInScope('alice', 'horde'); + + $this->assertNotEmpty($all, 'Config defaults must appear even when no DB rows exist'); + } + + // ----------------------------------------------------------------------- + // setValue() refuses writes to locked prefs + // ----------------------------------------------------------------------- + + /** + * setValue() must throw RuntimeException when the pref is locked in config. + */ + public function testSetValueThrowsForLockedPref(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'corporate', 'type' => 'select', 'locked' => true], + ]); + + // DB adapter should never be called for a locked pref + $db = $this->createMock(Horde_Db_Adapter::class); + $db->expects($this->never())->method('insert'); + $db->expects($this->never())->method('update'); + + $service = $this->makeService($db); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/locked/i'); + + $service->setValue('alice', 'horde', 'theme', 'custom'); + } + + /** + * setValue() must succeed (not throw) for an unlocked pref. + */ + public function testSetValueSucceedsForUnlockedPref(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'silver', 'type' => 'select'], // not locked + ]); + + $service = $this->makeService($this->mockDbExpectingWrite()); + + // Must not throw + $service->setValue('alice', 'horde', 'theme', 'gold'); + $this->addToAssertionCount(1); // explicit assertion: no exception + } + + /** + * setValue() must succeed for a pref not mentioned in config at all. + * Unknown prefs are not locked by definition. + */ + public function testSetValueSucceedsForUnknownPref(): void + { + // Empty vendor config dir. No prefs defined + $service = $this->makeService($this->mockDbExpectingWrite()); + + $service->setValue('alice', 'horde', 'custom_flag', '1'); + $this->addToAssertionCount(1); + } + + // ----------------------------------------------------------------------- + // createPrefs() no longer requires $GLOBALS['registry'] + // ----------------------------------------------------------------------- + + /** + * getValue() must return the config default even when $GLOBALS['registry'] + * is absent (e.g. a PSR-15 route). This verifies that PrefsConfigLoader- + * Storage replaced Horde_Core_Prefs_Storage_Configuration. + */ + public function testGetValueReturnsConfigDefaultWithoutRegistry(): void + { + $this->writeVendorPrefs([ + 'language' => ['value' => 'de_DE', 'type' => 'select'], + ]); + + // Ensure the global is not set for this test + $previousRegistry = $GLOBALS['registry'] ?? null; + unset($GLOBALS['registry']); + + try { + // DB has no row for this key + $service = $this->makeService($this->mockDbReturning([])); + $value = $service->getValue('alice', 'horde', 'language'); + + $this->assertSame( + 'de_DE', + $value, + 'Config default must be visible without $GLOBALS["registry"]' + ); + } finally { + // Restore global state to avoid bleeding into other tests + if ($previousRegistry !== null) { + $GLOBALS['registry'] = $previousRegistry; + } + } + } + + /** + * isLocked() must return true for a pref locked in config even without + * $GLOBALS['registry']. + */ + public function testIsLockedReturnsTrueWithoutRegistry(): void + { + $this->writeVendorPrefs([ + 'theme' => ['value' => 'corporate', 'type' => 'select', 'locked' => true], + ]); + + $previousRegistry = $GLOBALS['registry'] ?? null; + unset($GLOBALS['registry']); + + try { + $service = $this->makeService($this->mockDbReturning([])); + $this->assertTrue($service->isLocked('alice', 'horde', 'theme')); + } finally { + if ($previousRegistry !== null) { + $GLOBALS['registry'] = $previousRegistry; + } + } + } +}