diff --git a/composer.json b/composer.json index ee37480..cc2edcc 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "symfony/http-foundation": "^7.4" }, "require-dev": { + "predis/predis": "~3.3", "simplesamlphp/simplesamlphp-test-framework": "^1.11" }, "support": { diff --git a/docs/consent.md b/docs/consent.md index c3cae46..1b48f52 100644 --- a/docs/consent.md +++ b/docs/consent.md @@ -47,7 +47,7 @@ always be asked to give consent each time she logs in. ## Using storage -The consent module is shipped with two storage options, Cookie and Database. +The consent module is shipped with three storage options: Cookie, Database, and Redis. ### Using cookies as storage @@ -60,7 +60,7 @@ Example: 90 => [ 'class' => 'consent:Consent', 'identifyingAttribute' => 'uid', - 'store' => 'consent:Cookie', + 'store' => 'consent:Cookie', ], ``` @@ -130,7 +130,7 @@ Example config using PostgreSQL database: 'class' => 'consent:Consent', 'identifyingAttribute' => 'uid', 'store' => [ - 'consent:Database', + 'consent:Database', 'dsn' => 'pgsql:host=sql.example.org;dbname=consent', 'username' => 'simplesaml', 'password' => 'sdfsdf', @@ -145,7 +145,7 @@ Example config using MySQL database: 'class' => 'consent:Consent', 'identifyingAttribute' => 'uid', 'store' => [ - 'consent:Database', + 'consent:Database', 'dsn' => 'mysql:host=db.example.org;dbname=simplesaml', 'username' => 'simplesaml', 'password' => 'sdfsdf', @@ -153,6 +153,121 @@ Example config using MySQL database: ], ``` +### Using Redis as storage + +In order to use the Redis storage backend, you need to configure the consent +store to use `consent:Redis`. + +Example configuration: + +```php +90 => [ + 'class' => 'consent:Consent', + 'identifyingAttribute' => 'uid', + 'store' => [ + 'consent:Redis', + 'host' => 'redis.example.org', + 'port' => 6379, + 'database' => 0, + 'prefix' => 'SimpleSAMLphp', + 'password' => 'secret', + ], +], +``` + +The Redis backend stores consent by user and destination in a Redis hash, and +uses the same configuration conventions as the rest of SimpleSAMLphp Redis +based components. + +The `consent:Redis` backend supports the following options derived from +the base config: + +`host` +: Redis hostname. Optional. Defaults to `localhost`. + +`port` +: Redis TCP port. Optional. Defaults to `6379`. + +`database` +: Redis database number. Optional. Defaults to `0`. + +`prefix` +: Prefix prepended to all Redis keys. Optional. Defaults to + `SimpleSAMLphp`. + +`username` +: Redis username for ACL authentication. Optional. + +`password` +: Redis password. Optional. + +`tls` +: Enable TLS for Redis connections. Optional. Defaults to `false`. + +`insecure` +: Skip certificate validation when using TLS. Optional. Defaults to + `false`. + +`ca_certificate` +: Path to the CA certificate used for Redis TLS validation. Optional. + +`certificate` +: Path to the client certificate used for mutual TLS. Optional. + +`privatekey` +: Path to the private key used for mutual TLS. Optional. + +`sentinels` +: Array of Redis Sentinel endpoints. Optional. + +`mastergroup` +: Sentinel master group name. Optional. Defaults to `mymaster`. + +Local-only options: + +`lifetime` +: Consent lifetime in seconds. Optional. When set, the Redis consent key is + given an expiry using `EXPIRE` after each save. This option is local to the + consent module store configuration and is not read from global + `store.redis.*` settings. + +`inheritGlobal` +: Whether this store should inherit the global SimpleSAMLphp Redis + configuration (`store.redis.*`). Optional. Defaults to `true`. + +When a local Redis option is set in the module configuration, it always takes precedence; when it is not set and `inheritGlobal` is `true`, the store falls back to `store.redis.*` from global config, and if no global value exists (or if `inheritGlobal` is `false`) it falls back to the module default, so the effective order is `local > global > default` with inheritance enabled and `local > default` with inheritance disabled. + +Example using Sentinel-based Redis: + +```php +90 => [ + 'class' => 'consent:Consent', + 'identifyingAttribute' => 'uid', + 'store' => [ + 'consent:Redis', + 'inheritGlobal' => false, + 'sentinels' => [ + 'tcp://redis-sentinel-1:26379', + 'tcp://redis-sentinel-2:26379', + ], + 'mastergroup' => 'mymaster', + 'prefix' => 'consent_', + ], +], +``` + +The global Redis configuration can also be used directly for compatibility with +other SimpleSAMLphp Redis integrations: + +```php +'store.redis.host' => 'redis.example.org', +'store.redis.port' => 6379, +'store.redis.database' => 0, +'store.redis.prefix' => 'SimpleSAMLphp', +'store.redis.sentinels' => ['tcp://redis-sentinel-1:26379'], +'store.redis.mastergroup' => 'mymaster', +``` + ------- The following options can be used when configuring the Consent module: diff --git a/src/Consent/Store/Redis.php b/src/Consent/Store/Redis.php new file mode 100644 index 0000000..84c2e37 --- /dev/null +++ b/src/Consent/Store/Redis.php @@ -0,0 +1,472 @@ + + */ + private array $tls = []; + + /** + * Optional list of sentinel endpoints. + * + * @var string[] + */ + private array $sentinels = []; + + /** + * Sentinel master group. + */ + private ?string $masterGroup = null; + + /** + * Optional consent lifetime in seconds. + */ + private ?int $lifetime = null; + + /** + * Redis client handle. + */ + private ?Client $redis = null; + + + /** + * Parse configuration. + * + * @param array $config Configuration for Redis consent store. + * + * @throws \Exception in case of a configuration error. + */ + public function __construct(array $config = [], ?Client $redis = null) + { + parent::__construct($config); + Assert::isArray($config, 'consent:Redis - Configuration should be an array.'); + Assert::classExists(Client::class, 'consent:Redis - predis/predis is not available.'); + + $globalConfig = Configuration::getInstance(); + $cfg = Configuration::loadFromArray($config, 'consent:Consent'); + $inheritGlobal = $cfg->getOptionalBoolean('inheritGlobal', true,); + + /** + * anonymous helper function to resolve configuration values with optional inheritance from global config. + */ + $resolveOption = function ( + string $key, + mixed $default, + ) use ( + $globalConfig, + $cfg, + $inheritGlobal, + ) { + if ($cfg->hasValue($key)) { + return $cfg->getOptionalValue($key, $default); + } + + $globalKey = 'store.redis.' . $key; + if ($inheritGlobal && $globalConfig->hasValue($globalKey)) { + return $globalConfig->getOptionalValue($globalKey, $default); + } + + return $default; + }; + + $this->host = $resolveOption('host', 'localhost'); + $this->port = $resolveOption('port', 6379); + $this->prefix = $resolveOption('prefix', 'SimpleSAMLphp'); + $this->password = $resolveOption('password', null); + $this->username = $resolveOption('username', null); + $this->database = $resolveOption('database', 0); + $tls = $resolveOption('tls', false); + $this->sentinels = $resolveOption('sentinels', []); + + /* a maximum lifetime for consent can be configured, after which the consent will be automatically deleted */ + if ($cfg->hasValue('lifetime')) { + $lifetime = $cfg->getOptionalInteger('lifetime', 0); + Assert::greaterThan( + $lifetime, + 0, + 'consent:Redis - "lifetime" must be a positive integer when configured.', + ); + $this->lifetime = $lifetime; + } + + if ($tls) { + if ($resolveOption('insecure', false) === true) { + $this->tls['verify_peer'] = false; + $this->tls['verify_peer_name'] = false; + } else { + $ca = $resolveOption('ca_certificate', null); + if ($ca !== null) { + $this->tls['cafile'] = $ca; + } + } + + $key = $resolveOption('privatekey', null); + $cert = $resolveOption('certificate', null); + if ($cert !== null && $key !== null) { + $this->tls['local_cert'] = $cert; + $this->tls['local_pk'] = $key; + } + } + + if (!empty($this->sentinels)) { + $this->masterGroup = $resolveOption('mastergroup', 'mymaster'); + } + + if ($redis !== null) { + $this->redis = $redis; + } + } + + + /** + * Called before serialization. + * + * @return string[] The variables which should be serialized. + */ + public function __sleep(): array + { + return [ + 'host', + 'port', + 'database', + 'prefix', + 'username', + 'password', + 'tls', + 'sentinels', + 'masterGroup', + 'lifetime', + ]; + } + + + /** + * Clean up the Redis connection when this object is destroyed. + */ + public function __destruct() + { + if ($this->redis !== null) { + $this->redis->disconnect(); + } + } + + + /** + * Check for consent. + * + * @param string $userId The hash identifying the user at an IdP. + * @param string $destinationId A string which identifies the destination. + * @param string $attributeSet A hash which identifies the attributes. + * + * @return bool True if the user has given consent earlier, false if not. + */ + public function hasConsent(string $userId, string $destinationId, string $attributeSet): bool + { + $storedAttributeSet = $this->getRedis()->hget($this->getUserKey($userId), $destinationId); + if ($storedAttributeSet === null) { + Logger::debug('consent:Redis - No consent found.'); + return false; + } + + if ($storedAttributeSet === $attributeSet) { + Logger::debug('consent:Redis - Consent found.'); + return true; + } + + Logger::info('consent:Redis - Attribute set changed from the last time consent was given.'); + return false; + } + + + /** + * Save consent. + * + * @param string $userId The hash identifying the user at an IdP. + * @param string $destinationId A string which identifies the destination. + * @param string $attributeSet A hash which identifies the attributes. + * + * @return bool True if consent is saved, false if it was updated. + */ + public function saveConsent(string $userId, string $destinationId, string $attributeSet): bool + { + $userKey = $this->getUserKey($userId); + $storedAttributeSet = $this->getRedis()->hget($userKey, $destinationId); + + if ($storedAttributeSet !== null && $storedAttributeSet === $attributeSet) { + Logger::debug('consent:Redis - Consent already stored.'); + return true; + } + + try { + $result = $this->getRedis()->hset($userKey, $destinationId, $attributeSet); + if ($result) { + Logger::debug('consent:Redis - Saved new consent.'); + } else { + Logger::debug('consent:Redis - Updated old consent.'); + } + + if ($this->lifetime !== null) { + $this->getRedis()->expire($userKey, $this->lifetime); + } + + return true; + } catch (\Exception $e) { + Logger::error('consent:Redis - Failed to save consent: ' . $e->getMessage()); + return false; + } + } + + + /** + * Delete consent. + * + * Called when a user revokes consent for a given destination. + * + * @param string $userId The hash identifying the user at an IdP. + * @param string $destinationId A string which identifies the destination. + * + * @return int Number of consents deleted. + */ + public function deleteConsent(string $userId, string $destinationId): int + { + try { + $deleted = $this->getRedis()->hdel($this->getUserKey($userId), [$destinationId]); + } catch (\Exception $e) { + Logger::error('consent:Redis - Failed to delete consent: ' . $e->getMessage()); + return 0; + } + + if ($deleted > 0) { + Logger::debug('consent:Redis - Deleted consent.'); + return $deleted; + } + + Logger::warning('consent:Redis - Attempted to delete nonexistent consent'); + return 0; + } + + + /** + * Delete all consents for a user. + * + * @param string $userId The hash identifying the user at an IdP. + * + * @return int Number of consents deleted. + */ + public function deleteAllConsents(string $userId): int + { + $userKey = $this->getUserKey($userId); + $count = $this->getRedis()->hlen($userKey); + + if ($count === 0) { + Logger::warning('consent:Redis - Attempted to delete nonexistent consent'); + return 0; + } + + try { + $deleted = $this->getRedis()->del($userKey); + } catch (\Exception $e) { + Logger::error('consent:Redis - Failed to delete consent(s): ' . $e->getMessage()); + return 0; + } + if ($deleted === 0) { + Logger::warning('consent:Redis - Failed to delete consent(s).'); + return 0; + } + + Logger::debug('consent:Redis - Deleted (' . $count . ') consent(s).'); + return $count; + } + + + /** + * Retrieve consents. + * + * @param string $userId The hash identifying the user at an IdP. + * + * @return string[] Array of all destination ids the user has given consent for. + */ + public function getConsents(string $userId): array + { + $key = $this->getUserKey($userId); + if (!$this->getRedis()->exists($key)) { + return []; + } + + return $this->getRedis()->hkeys($key); + } + + + /** + * Get statistics for all consent given in the consent store. + * + * @return array Statistics from the consent store. + */ + public function getStatistics(): array + { + $redisKeys = $this->getRedis()->keys($this->getKeyPattern()); + $ret = [ + 'total' => 0, + 'users' => count($redisKeys), + 'services' => 0, + ]; + + foreach ($redisKeys as $key) { + $consents = $this->getRedis()->hkeys($key); + $ret['total'] += count($consents); + foreach ($consents as $destination) { + $ret['services'] = max($ret['services'], 0); + $ret['services']++; + } + } + + $uniqueServices = []; + foreach ($redisKeys as $key) { + foreach ($this->getRedis()->hkeys($key) as $destination) { + $uniqueServices[$destination] = true; + } + } + $ret['services'] = count($uniqueServices); + + return $ret; + } + + + /** + * Get a connected Redis client, initializing lazily when needed. + * + * @return \Predis\Client The configured client. + */ + private function getRedis(): Client + { + if ($this->redis !== null) { + return $this->redis; + } + + $connection = [ + 'scheme' => empty($this->tls) ? 'tcp' : 'tls', + 'host' => $this->host, + 'port' => $this->port, + 'database' => $this->database, + ]; + + if (!empty($this->tls)) { + $connection['ssl'] = $this->tls; + } + if ($this->username !== null && $this->username !== '') { + $connection['username'] = $this->username; + } + if ($this->password !== null && $this->password !== '') { + $connection['password'] = $this->password; + } + + if (empty($this->sentinels)) { + // single redis instance + $this->redis = new Client( + $connection, + ['prefix' => $this->prefix], + ); + } else { + // redis sentinel setup + $this->redis = new Client( + $this->sentinels, + [ + 'replication' => 'sentinel', + 'service' => $this->masterGroup, + 'prefix' => $this->prefix, + 'parameters' => [ + 'scheme' => empty($this->tls) ? 'tcp' : 'tls', + 'database' => $this->database, + ] + (empty($this->tls) ? [] : ['ssl' => $this->tls]) + + ($this->username !== null && $this->username !== '' ? ['username' => $this->username] : []) + + ($this->password !== null && $this->password !== '' ? ['password' => $this->password] : []), + ], + ); + } + return $this->redis; + } + + + /** + * The key used for consent data for a specific user. + * + * @param string $userId The hash identifying the user at an IdP. + * + * @return string Redis key. + */ + private function getUserKey(string $userId): string + { + return $this->prefix . 'consent:' . $userId; + } + + + /** + * Pattern used to list all consent hashes. + * + * @return string Redis key glob. + */ + private function getKeyPattern(): string + { + return $this->prefix . 'consent:*'; + } +} diff --git a/tests/src/Consent/Store/RedisTest.php b/tests/src/Consent/Store/RedisTest.php new file mode 100644 index 0000000..decc3f5 --- /dev/null +++ b/tests/src/Consent/Store/RedisTest.php @@ -0,0 +1,402 @@ + 'abc123', + 'module.enable' => ['consent'], + ], + '[ARRAY]', + 'simplesaml', + ); + Configuration::setPreLoadedConfig($config, 'config.php'); + + /* + * Create a mock Redis client that simulates the behavior of a Redis server. + * This allows us to test the Redis store without needing an actual Redis server. + * The @method annotations are directly from https://github.com/predis/predis/blob/v3.6.0/src/ClientInterface.php + */ + $this->client = new class () extends Client { + /** @var array */ + private array $data = []; + + /** @var array */ + private array $ttl = []; + + + public function __construct() + { + } + + + /** @method string|null hget(string $key, string $field) */ + public function hget(string $key, string $field): ?string + { + $store = $this->data[$key] ?? []; + return array_key_exists($field, $store) ? (string) $store[$field] : null; + } + + + /** @method int hset(string $key, string $field, string $value) */ + public function hset(string $key, string $field, string $value): int + { + if (array_key_exists($field, $this->data[$key] ?? [])) { + $this->data[$key][$field] = $value; + return 0; + } + + $this->data[$key][$field] = $value; + return 1; + } + + + /** @method int hdel(string $key, array $fields) */ + public function hdel(string $key, array $fields): int + { + $destroyed = 0; + foreach ($fields as $field) { + if (isset($this->data[$key][$field])) { + unset($this->data[$key][$field]); + $destroyed++; + } + } + if (empty($this->data[$key])) { + unset($this->data[$key]); + } + return $destroyed; + } + + + /** @method array hkeys(string $key) */ + public function hkeys(string $key): array + { + return array_keys($this->data[$key] ?? []); + } + + + /** @method int hlen(string $key) */ + public function hlen(string $key): int + { + return count($this->data[$key] ?? []); + } + + + /** @method int exists(string $key) */ + public function exists(string $key): int + { + return array_key_exists($key, $this->data) ? 1 : 0; + } + + + /** @method int del(string[]|string $keyOrKeys, string ...$keys = null) */ + public function del(string ...$keys): int + { + $deleted = 0; + foreach ($keys as $key) { + if (isset($this->data[$key])) { + unset($this->data[$key]); + unset($this->ttl[$key]); + $deleted++; + } + } + return $deleted; + } + + + /** @method int expire(string $key, int $seconds) */ + public function expire(string $key, int $seconds): int + { + if (!array_key_exists($key, $this->data)) { + return 0; + } + + $this->ttl[$key] = $seconds; + return 1; + } + + + /** @method int ttl(string $key) */ + public function ttl(string $key): int + { + return $this->ttl[$key] ?? -1; + } + + + /** @method array keys(string $pattern) */ + public function keys(string $pattern): array + { + $keys = []; + foreach (array_keys($this->data) as $key) { + if (preg_match('/' . str_replace('*', '.*', preg_quote($pattern, '/')) . '/', $key) === 1) { + $keys[] = $key; + } + } + return $keys; + } + + + /** @method mixed ping(?string $message = null) */ + public function ping(?string $message = null): mixed + { + return $message ?? new \Predis\Response\Status('PONG'); + } + + + /** @method string|null get(string $key) */ + public function get(string $key): ?string + { + return $this->data[$key] ?? null; + } + + + /** @method \Predis\Response\Status|null set(string $key, $value, $expireResolution = null, $expireTTL = null, $flag = null, $flagValue = null) */ + public function set( // @phpstan-ignore return.unusedType + string $key, + string $value, + $expireResolution = null, + $expireTTL = null, + $flag = null, + $flagValue = null, + ): ?\Predis\Response\Status { + $this->data[$key] = $value; + return new \Predis\Response\Status('OK'); + } + + + /** @method disconnect() */ + public function disconnect() + { + } + }; + + $this->store = new Redis(['prefix' => ''], $this->client); + } + + + public function testHasConsentReturnsTrueForKnownAttributeSet(): void + { + $this->client->hset('consent:user1', 'destination1', 'attributes-1'); + + $this->assertTrue($this->store->hasConsent('user1', 'destination1', 'attributes-1')); + $this->assertFalse($this->store->hasConsent('user1', 'destination1', 'attributes-2')); + } + + + public function testSaveConsentStoresUserConsent(): void + { + $this->assertTrue($this->store->saveConsent('user1', 'destination1', 'attributes-1')); + $this->assertSame('attributes-1', $this->client->hget('consent:user1', 'destination1')); + } + + + public function testSaveConsentReturnsTrueWhenUpdatingExistingConsent(): void + { + $this->store->saveConsent('user1', 'destination1', 'attributes-1'); + + $this->assertTrue($this->store->saveConsent('user1', 'destination1', 'attributes-2')); + } + + + public function testSaveConsentSetsExpiryWhenLifetimeIsConfigured(): void + { + $store = new Redis(['prefix' => '', 'lifetime' => 120], $this->client); + + $this->assertTrue($store->saveConsent('user1', 'destination1', 'attributes-1')); + $this->assertSame(120, $this->client->ttl('consent:user1')); + } + + + public function testSaveConsentDoesNotSetExpiryWhenLifetimeIsNotConfigured(): void + { + $this->assertTrue($this->store->saveConsent('user1', 'destination1', 'attributes-1')); + $this->assertSame(-1, $this->client->ttl('consent:user1')); + } + + + public function testConstructorRejectsNonPositiveLifetime(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('consent:Redis - "lifetime" must be a positive integer when configured.'); + + new Redis(['lifetime' => 0], $this->client); + } + + + public function testDeleteConsentRemovesSingleDestination(): void + { + $this->store->saveConsent('user1', 'destination1', 'attributes-1'); + $this->store->saveConsent('user1', 'destination2', 'attributes-2'); + + $this->assertSame(1, $this->store->deleteConsent('user1', 'destination1')); + $this->assertFalse($this->store->hasConsent('user1', 'destination1', 'attributes-1')); + $this->assertTrue($this->store->hasConsent('user1', 'destination2', 'attributes-2')); + } + + + public function testDeleteAllConsentsRemovesAllForUser(): void + { + $this->store->saveConsent('user1', 'destination1', 'attributes-1'); + $this->store->saveConsent('user1', 'destination2', 'attributes-2'); + + $this->assertSame(2, $this->store->deleteAllConsents('user1')); + $this->assertSame([], $this->store->getConsents('user1')); + } + + + public function testGetConsentsReturnsStoredDestinationIds(): void + { + $this->store->saveConsent('user1', 'destination1', 'attributes-1'); + $this->store->saveConsent('user1', 'destination2', 'attributes-2'); + $this->store->saveConsent('user2', 'destination3', 'attributes-3'); + + $this->assertSame(['destination1', 'destination2'], $this->store->getConsents('user1')); + } + + + public function testGetStatisticsCountsUsersAndServices(): void + { + $this->store->saveConsent('user1', 'destination1', 'attributes-1'); + $this->store->saveConsent('user1', 'destination2', 'attributes-2'); + $this->store->saveConsent('user2', 'destination2', 'attributes-3'); + + $this->assertSame([ + 'total' => 3, + 'users' => 2, + 'services' => 2, + ], $this->store->getStatistics()); + } + + + public function testConstructorUsesGlobalRedisValuesWhenLocalValuesAreUndefined(): void + { + $config = Configuration::loadFromArray([ + 'store.redis.host' => 'redis.example.org', + 'store.redis.port' => 6380, + 'store.redis.prefix' => 'consent_', + 'store.redis.database' => 7, + 'store.redis.password' => 'secret', + 'store.redis.username' => 'consent-user', + 'store.redis.sentinels' => ['tcp://sentinel1'], + 'store.redis.mastergroup' => 'master', + ], '[ARRAY]', 'simplesaml'); + + Configuration::setPreLoadedConfig($config, 'config.php'); + + $store = new Redis(['prefix' => ''], $this->client); + + $reflection = new \ReflectionClass($store); + $this->assertSame('redis.example.org', $reflection->getProperty('host')->getValue($store)); + $this->assertSame(6380, $reflection->getProperty('port')->getValue($store)); + $this->assertSame(7, $reflection->getProperty('database')->getValue($store)); + $this->assertSame('', $reflection->getProperty('prefix')->getValue($store)); + $this->assertSame('secret', $reflection->getProperty('password')->getValue($store)); + $this->assertSame('consent-user', $reflection->getProperty('username')->getValue($store)); + $this->assertSame('master', $reflection->getProperty('masterGroup')->getValue($store)); + $this->assertSame(['tcp://sentinel1'], $reflection->getProperty('sentinels')->getValue($store)); + } + + + public function testConstructorPrefersLocalConfigOverGlobalValues(): void + { + $config = Configuration::loadFromArray([ + 'store.redis.host' => 'redis.example.org', + 'store.redis.port' => 6380, + 'store.redis.prefix' => 'consent_', + 'store.redis.database' => 7, + 'store.redis.password' => 'secret', + 'store.redis.username' => 'consent-user', + 'store.redis.sentinels' => ['tcp://sentinel1'], + 'store.redis.mastergroup' => 'master', + ], '[ARRAY]', 'simplesaml'); + + Configuration::setPreLoadedConfig($config, 'config.php'); + + $store = new Redis([ + 'host' => 'local.redis.example.org', + 'port' => 6390, + 'database' => 9, + 'prefix' => 'module_', + 'password' => 'local-secret', + 'username' => 'module-user', + 'mastergroup' => 'local-master', + 'sentinels' => ['tcp://local-sentinel1'], + ], $this->client); + + $reflection = new \ReflectionClass($store); + $this->assertSame('local.redis.example.org', $reflection->getProperty('host')->getValue($store)); + $this->assertSame(6390, $reflection->getProperty('port')->getValue($store)); + $this->assertSame(9, $reflection->getProperty('database')->getValue($store)); + $this->assertSame('module_', $reflection->getProperty('prefix')->getValue($store)); + $this->assertSame('local-secret', $reflection->getProperty('password')->getValue($store)); + $this->assertSame('module-user', $reflection->getProperty('username')->getValue($store)); + $this->assertSame('local-master', $reflection->getProperty('masterGroup')->getValue($store)); + $this->assertSame(['tcp://local-sentinel1'], $reflection->getProperty('sentinels')->getValue($store)); + } + + + public function testConstructorUsesDefaultsWhenGlobalInheritanceIsDisabled(): void + { + $config = Configuration::loadFromArray([ + 'store.redis.host' => 'redis.example.org', + 'store.redis.port' => 6380, + 'store.redis.prefix' => 'consent_', + 'store.redis.database' => 7, + 'store.redis.password' => 'secret', + 'store.redis.username' => 'consent-user', + 'store.redis.sentinels' => ['tcp://sentinel1'], + 'store.redis.mastergroup' => 'master', + ], '[ARRAY]', 'simplesaml'); + + Configuration::setPreLoadedConfig($config, 'config.php'); + + $store = new Redis([ + 'inheritGlobal' => false, + ], $this->client); + + $reflection = new \ReflectionClass($store); + $this->assertSame('localhost', $reflection->getProperty('host')->getValue($store)); + $this->assertSame(6379, $reflection->getProperty('port')->getValue($store)); + $this->assertSame(0, $reflection->getProperty('database')->getValue($store)); + $this->assertSame('SimpleSAMLphp', $reflection->getProperty('prefix')->getValue($store)); + $this->assertNull($reflection->getProperty('password')->getValue($store)); + $this->assertNull($reflection->getProperty('username')->getValue($store)); + $this->assertNull($reflection->getProperty('masterGroup')->getValue($store)); + $this->assertSame([], $reflection->getProperty('sentinels')->getValue($store)); + } + + + public function testStoreInitializesRedisClientLazilyAfterUnserialize(): void + { + $store = new Redis([ + 'host' => 'localhost', + 'port' => 6379, + 'database' => 0, + 'prefix' => '', + 'inheritGlobal' => false, + ], $this->client); + + $restored = unserialize(serialize($store)); + $this->assertInstanceOf(Redis::class, $restored); + + $reflection = new \ReflectionClass($restored); + $this->assertNull($reflection->getProperty('redis')->getValue($restored)); + } +}