diff --git a/config/packages/engineblock_features.yaml b/config/packages/engineblock_features.yaml index 8e7ed423c1..b1a3d003d4 100644 --- a/config/packages/engineblock_features.yaml +++ b/config/packages/engineblock_features.yaml @@ -16,3 +16,4 @@ parameters: eb.stepup.sfo.override_engine_entityid: "%feature_stepup_sfo_override_engine_entityid%" eb.stepup.send_user_attributes: "%feature_stepup_send_user_attributes%" eb.feature_enable_sram_interrupt: "%feature_enable_sram_interrupt%" + eb.stable_consent_hash_migration: "%feature_stable_consent_hash_migration%" diff --git a/config/packages/parameters.yml.dist b/config/packages/parameters.yml.dist index cfc3509c5c..0e29ec405b 100644 --- a/config/packages/parameters.yml.dist +++ b/config/packages/parameters.yml.dist @@ -224,6 +224,7 @@ parameters: feature_stepup_sfo_override_engine_entityid: false feature_stepup_send_user_attributes: false feature_enable_sram_interrupt: false + feature_stable_consent_hash_migration: false ########################################################################################## ## PROFILE SETTINGS diff --git a/config/services/compat.yml b/config/services/compat.yml index 26b8946cf6..883cd1d12b 100644 --- a/config/services/compat.yml +++ b/config/services/compat.yml @@ -50,7 +50,7 @@ services: class: EngineBlock_Corto_Model_Consent_Factory arguments: - "@engineblock.compat.corto_filter_command_factory" - - "@engineblock.compat.database_connection_factory" + - "@engineblock.service.consent.ConsentHashService" engineblock.compat.saml2_id_generator: public: true diff --git a/config/services/controllers/api.yml b/config/services/controllers/api.yml index 90686d60ff..6a88a67570 100644 --- a/config/services/controllers/api.yml +++ b/config/services/controllers/api.yml @@ -16,7 +16,7 @@ services: - '@security.token_storage' - '@security.access.decision_manager' - '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration' - - '@OpenConext\EngineBlock\Service\ConsentService' + - '@OpenConext\EngineBlock\Service\Consent\ConsentService' OpenConext\EngineBlockBundle\Controller\Api\DeprovisionController: arguments: diff --git a/config/services/services.yml b/config/services/services.yml index 9cecb712ef..1ee0e3032c 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -75,7 +75,14 @@ services: - '@OpenConext\EngineBlock\Metadata\LoaRepository' - '@logger' - OpenConext\EngineBlock\Service\ConsentService: + engineblock.service.consent.ConsentHashService: + class: OpenConext\EngineBlock\Service\Consent\ConsentHashService + public: false + arguments: + - '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository' + - '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration' + + OpenConext\EngineBlock\Service\Consent\ConsentService: arguments: - '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository' - '@OpenConext\EngineBlock\Service\MetadataService' diff --git a/library/EngineBlock/Application/DiContainer.php b/library/EngineBlock/Application/DiContainer.php index 0916e348a8..03d4a6f3e6 100644 --- a/library/EngineBlock/Application/DiContainer.php +++ b/library/EngineBlock/Application/DiContainer.php @@ -161,11 +161,11 @@ public function getAuthenticationLoopGuard() } /** - * @return OpenConext\EngineBlock\Service\ConsentService + * @return OpenConext\EngineBlock\Service\Consent\ConsentService */ public function getConsentService() { - return $this->container->get(\OpenConext\EngineBlock\Service\ConsentService::class); + return $this->container->get(\OpenConext\EngineBlock\Service\Consent\ConsentService::class); } /** diff --git a/library/EngineBlock/Corto/Model/Consent.php b/library/EngineBlock/Corto/Model/Consent.php index 40bd3e1ef6..65e76239d3 100644 --- a/library/EngineBlock/Corto/Model/Consent.php +++ b/library/EngineBlock/Corto/Model/Consent.php @@ -16,9 +16,13 @@ * limitations under the License. */ -use Doctrine\DBAL\Statement; +use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery; +use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters; +use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Authentication\Value\ConsentType; +use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface; class EngineBlock_Corto_Model_Consent { @@ -37,15 +41,10 @@ class EngineBlock_Corto_Model_Consent */ private $_response; /** - * @var array + * @var array All attributes as an associative array. */ private $_responseAttributes; - /** - * @var EngineBlock_Database_ConnectionFactory - */ - private $_databaseConnectionFactory; - /** * A reflection of the eb.run_all_manipulations_prior_to_consent feature flag * @@ -61,63 +60,82 @@ class EngineBlock_Corto_Model_Consent private $_consentEnabled; /** - * @param string $tableName - * @param bool $mustStoreValues - * @param EngineBlock_Saml2_ResponseAnnotationDecorator $response - * @param array $responseAttributes - * @param EngineBlock_Database_ConnectionFactory $databaseConnectionFactory + * @var ConsentHashServiceInterface + */ + private $_hashService; + + /** * @param bool $amPriorToConsentEnabled Is the run_all_manipulations_prior_to_consent feature enabled or not - * @param bool $consentEnabled Is the feature_enable_consent feature enabled or not */ public function __construct( - $tableName, - $mustStoreValues, + string $tableName, + bool $mustStoreValues, EngineBlock_Saml2_ResponseAnnotationDecorator $response, array $responseAttributes, - EngineBlock_Database_ConnectionFactory $databaseConnectionFactory, - $amPriorToConsentEnabled, - $consentEnabled - ) - { + bool $amPriorToConsentEnabled, + bool $consentEnabled, + ConsentHashServiceInterface $hashService + ) { $this->_tableName = $tableName; $this->_mustStoreValues = $mustStoreValues; $this->_response = $response; $this->_responseAttributes = $responseAttributes; - $this->_databaseConnectionFactory = $databaseConnectionFactory; $this->_amPriorToConsentEnabled = $amPriorToConsentEnabled; + $this->_hashService = $hashService; $this->_consentEnabled = $consentEnabled; } - public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider) + public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider): ConsentVersion { - return !$this->_consentEnabled || - $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); + if (!$this->_consentEnabled) { + // Consent disabled: treat as already given (stable — no upgrade needed) + return ConsentVersion::stable(); + } + return $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); } - public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider) + /** + * Although the user has given consent previously we want to upgrade the deprecated unstable consent + * to the new stable consent type. + * https://www.pivotaltracker.com/story/show/176513931 + * + * The caller must pass the ConsentVersion already retrieved by explicitConsentWasGivenFor or + * implicitConsentWasGivenFor to avoid a second identical DB query. + */ + public function upgradeAttributeHashFor(ServiceProvider $serviceProvider, string $consentType, ConsentVersion $consentVersion): void { - return !$this->_consentEnabled || - $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); + if (!$this->_consentEnabled) { + return; + } + if ($consentVersion->isUnstable()) { + $this->_updateConsent($serviceProvider, $consentType); + } } - public function giveExplicitConsentFor(ServiceProvider $serviceProvider) + public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider): ConsentVersion + { + if (!$this->_consentEnabled) { + return ConsentVersion::stable(); + } + return $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); + } + + public function giveExplicitConsentFor(ServiceProvider $serviceProvider): bool { return !$this->_consentEnabled || $this->_storeConsent($serviceProvider, ConsentType::TYPE_EXPLICIT); } - public function giveImplicitConsentFor(ServiceProvider $serviceProvider) + public function giveImplicitConsentFor(ServiceProvider $serviceProvider): bool { return !$this->_consentEnabled || $this->_storeConsent($serviceProvider, ConsentType::TYPE_IMPLICIT); } - /** - * @return Doctrine\DBAL\Connection - */ - protected function _getConsentDatabaseConnection() + public function countTotalConsent(): int { - return $this->_databaseConnectionFactory->create(); + $consentUid = $this->_getConsentUid(); + return $this->_hashService->countTotalConsent($consentUid); } protected function _getConsentUid() @@ -129,116 +147,66 @@ protected function _getConsentUid() return $this->_response->getNameIdValue(); } - protected function _getAttributesHash($attributes) + protected function _getAttributesHash($attributes): string { - $hashBase = NULL; - if ($this->_mustStoreValues) { - ksort($attributes); - $hashBase = serialize($attributes); - } else { - $names = array_keys($attributes); - sort($names); - $hashBase = implode('|', $names); - } - return sha1($hashBase); + return $this->_hashService->getUnstableAttributesHash($attributes, $this->_mustStoreValues); } - private function _storeConsent(ServiceProvider $serviceProvider, $consentType) + protected function _getStableAttributesHash($attributes): string { - $dbh = $this->_getConsentDatabaseConnection(); - if (!$dbh) { - return false; - } + return $this->_hashService->getStableAttributesHash($attributes, $this->_mustStoreValues); + } + private function _storeConsent(ServiceProvider $serviceProvider, $consentType): bool + { $consentUuid = $this->_getConsentUid(); - if(! is_string($consentUuid)){ + if (!is_string($consentUuid)) { return false; } - $query = "INSERT INTO consent (hashed_user_id, service_id, attribute, consent_type, consent_date, deleted_at) - VALUES (?, ?, ?, ?, NOW(), '0000-00-00 00:00:00') - ON DUPLICATE KEY UPDATE attribute=VALUES(attribute), consent_type=VALUES(consent_type), consent_date=NOW()"; - $parameters = array( - sha1($consentUuid), - $serviceProvider->entityId, - $this->_getAttributesHash($this->_responseAttributes), - $consentType, + $parameters = new ConsentStoreParameters( + hashedUserId: sha1($consentUuid), + serviceId: $serviceProvider->entityId, + attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes), + consentType: $consentType, + attributeHash: $this->_getAttributesHash($this->_responseAttributes), ); - $statement = $dbh->prepare($query); - if (!$statement) { - throw new EngineBlock_Exception( - "Unable to create a prepared statement to insert consent?!", - EngineBlock_Exception::CODE_CRITICAL - ); - } + return $this->_hashService->storeConsentHash($parameters); + } - assert($statement instanceof Statement); - try{ - foreach ($parameters as $index => $parameter){ - $statement->bindValue($index + 1, $parameter); - } - - $statement->executeStatement(); - }catch (\Doctrine\DBAL\Exception $e){ - throw new EngineBlock_Corto_Module_Services_Exception( - sprintf('Error storing consent: "%s"', var_export($e->getMessage(), true)), - EngineBlock_Exception::CODE_CRITICAL - ); + private function _updateConsent(ServiceProvider $serviceProvider, $consentType): bool + { + $consentUid = $this->_getConsentUid(); + if (!is_string($consentUid)) { + return false; } - return true; + + $parameters = new ConsentUpdateParameters( + attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes), + attributeHash: $this->_getAttributesHash($this->_responseAttributes), + hashedUserId: sha1($consentUid), + serviceId: $serviceProvider->entityId, + consentType: $consentType, + ); + + return $this->_hashService->updateConsentHash($parameters); } - private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType) + private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType): ConsentVersion { - try { - $dbh = $this->_getConsentDatabaseConnection(); - if (!$dbh) { - return false; - } - - $attributesHash = $this->_getAttributesHash($this->_responseAttributes); - - $consentUuid = $this->_getConsentUid(); - if (!is_string($consentUuid)) { - return false; - } - - $query = " - SELECT * - FROM {$this->_tableName} - WHERE hashed_user_id = ? - AND service_id = ? - AND attribute = ? - AND consent_type = ? - AND deleted_at IS NULL - "; - $hashedUserId = sha1($consentUuid); - $parameters = array( - $hashedUserId, - $serviceProvider->entityId, - $attributesHash, - $consentType, - ); - - $statement = $dbh->prepare($query); - assert($statement instanceof Statement); - foreach ($parameters as $position => $parameter) { - $statement->bindValue($position + 1, $parameter); - } - $rows = $statement->executeQuery(); - - if ($rows->rowCount() < 1) { - // No stored consent found - return false; - } - - return true; - } catch (PDOException $e) { - throw new EngineBlock_Corto_ProxyServer_Exception( - sprintf('Consent retrieval failed! Error: "%s"', $e->getMessage()), - EngineBlock_Exception::CODE_ALERT - ); + $consentUid = $this->_getConsentUid(); + if (!is_string($consentUid)) { + return ConsentVersion::notGiven(); } + + $query = new ConsentHashQuery( + hashedUserId: sha1($consentUid), + serviceId: $serviceProvider->entityId, + attributeHash: $this->_getAttributesHash($this->_responseAttributes), + attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes), + consentType: $consentType, + ); + return $this->_hashService->retrieveConsentHash($query); } } diff --git a/library/EngineBlock/Corto/Model/Consent/Factory.php b/library/EngineBlock/Corto/Model/Consent/Factory.php index 80be173e81..5efe60ab2c 100644 --- a/library/EngineBlock/Corto/Model/Consent/Factory.php +++ b/library/EngineBlock/Corto/Model/Consent/Factory.php @@ -16,6 +16,8 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface; + /** * @todo write a test */ @@ -24,21 +26,20 @@ class EngineBlock_Corto_Model_Consent_Factory /** @var EngineBlock_Corto_Filter_Command_Factory */ private $_filterCommandFactory; - /** @var EngineBlock_Database_ConnectionFactory */ - private $_databaseConnectionFactory; - + /** + * @var ConsentHashServiceInterface + */ + private $_hashService; - /** + /** * @param EngineBlock_Corto_Filter_Command_Factory $filterCommandFactory - * @param EngineBlock_Database_ConnectionFactory $databaseConnectionFactory */ public function __construct( EngineBlock_Corto_Filter_Command_Factory $filterCommandFactory, - EngineBlock_Database_ConnectionFactory $databaseConnectionFactory - ) - { + ConsentHashServiceInterface $hashService + ) { $this->_filterCommandFactory = $filterCommandFactory; - $this->_databaseConnectionFactory = $databaseConnectionFactory; + $this->_hashService = $hashService; } /** @@ -68,9 +69,9 @@ public function create( $proxyServer->getConfig('ConsentStoreValues', true), $response, $attributes, - $this->_databaseConnectionFactory, $amPriorToConsent, - $consentEnabled + $consentEnabled, + $this->_hashService ); } } diff --git a/library/EngineBlock/Corto/Module/Service/ProcessConsent.php b/library/EngineBlock/Corto/Module/Service/ProcessConsent.php index 97cf0300b6..69fdf3eda8 100644 --- a/library/EngineBlock/Corto/Module/Service/ProcessConsent.php +++ b/library/EngineBlock/Corto/Module/Service/ProcessConsent.php @@ -16,6 +16,7 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Authentication\Value\ConsentType; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; use SAML2\Constants; @@ -99,8 +100,11 @@ public function serve($serviceName, Request $httpRequest) $attributes = $response->getAssertion()->getAttributes(); $consentRepository = $this->_consentFactory->create($this->_server, $response, $attributes); - if (!$consentRepository->explicitConsentWasGivenFor($serviceProvider)) { + $explicitConsent = $consentRepository->explicitConsentWasGivenFor($serviceProvider); + if (!$explicitConsent->given()) { $consentRepository->giveExplicitConsentFor($destinationMetadata); + } else { + $consentRepository->upgradeAttributeHashFor($destinationMetadata, ConsentType::TYPE_EXPLICIT, $explicitConsent); } $response->setConsent(Constants::CONSENT_OBTAINED); diff --git a/library/EngineBlock/Corto/Module/Service/ProvideConsent.php b/library/EngineBlock/Corto/Module/Service/ProvideConsent.php index 767650ae83..f14668c5c8 100644 --- a/library/EngineBlock/Corto/Module/Service/ProvideConsent.php +++ b/library/EngineBlock/Corto/Module/Service/ProvideConsent.php @@ -16,10 +16,11 @@ * limitations under the License. */ +use OpenConext\EngineBlock\Authentication\Value\ConsentType; use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; use OpenConext\EngineBlockBundle\Service\DiscoverySelectionService; use Psr\Log\LogLevel; @@ -146,8 +147,11 @@ public function serve($serviceName, Request $httpRequest) if ($this->isConsentDisabled($spMetadataChain, $identityProvider)) { - if (!$consentRepository->implicitConsentWasGivenFor($serviceProviderMetadata)) { + $implicitConsent = $consentRepository->implicitConsentWasGivenFor($serviceProviderMetadata); + if (!$implicitConsent->given()) { $consentRepository->giveImplicitConsentFor($serviceProviderMetadata); + } else { + $consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_IMPLICIT, $implicitConsent); } $response->setConsent(Constants::CONSENT_INAPPLICABLE); @@ -165,7 +169,9 @@ public function serve($serviceName, Request $httpRequest) } $priorConsent = $consentRepository->explicitConsentWasGivenFor($serviceProviderMetadata); - if ($priorConsent) { + if ($priorConsent->given()) { + $consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_EXPLICIT, $priorConsent); + $response->setConsent(Constants::CONSENT_PRIOR); $response->setDestination($response->getReturn()); diff --git a/migrations/DoctrineMigrations/Version20260210000000.php b/migrations/DoctrineMigrations/Version20260210000000.php index 3277a46df5..adc81a0044 100644 --- a/migrations/DoctrineMigrations/Version20260210000000.php +++ b/migrations/DoctrineMigrations/Version20260210000000.php @@ -53,7 +53,8 @@ public function up(Schema $schema): void `consent_date` datetime NOT NULL, `hashed_user_id` varchar(80) NOT NULL, `service_id` varchar(255) NOT NULL, - `attribute` varchar(80) NOT NULL, + `attribute` varchar(80) DEFAULT NULL, + `attribute_stable` varchar(80) DEFAULT NULL, `consent_type` varchar(20) DEFAULT \'explicit\', `deleted_at` datetime NOT NULL DEFAULT \'0000-00-00 00:00:00\', PRIMARY KEY (`hashed_user_id`,`service_id`,`deleted_at`), diff --git a/migrations/DoctrineMigrations/Version20260315000001.php b/migrations/DoctrineMigrations/Version20260315000001.php new file mode 100644 index 0000000000..c2cc56a79f --- /dev/null +++ b/migrations/DoctrineMigrations/Version20260315000001.php @@ -0,0 +1,55 @@ +connection->createSchemaManager()->listTableNames(); + $tableExists = in_array('consent', $tables, true); + + if (!$tableExists) { + $this->skipIf(true, 'Table consent does not exist yet (fresh install, baseline will create it). Skipping.'); + return; + } + + $columnExists = (bool) $this->connection->fetchOne( + "SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'consent' + AND COLUMN_NAME = 'attribute_stable'" + ); + $this->skipIf( + $columnExists, + 'Column attribute_stable already exists (fresh install via baseline). Skipping.' + ); + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE consent ADD attribute_stable VARCHAR(80) DEFAULT NULL, CHANGE attribute attribute VARCHAR(80) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('UPDATE consent SET attribute = attribute_stable WHERE attribute IS NULL AND attribute_stable IS NOT NULL'); + $this->addSql('ALTER TABLE consent CHANGE attribute attribute VARCHAR(80) NOT NULL'); + $this->addSql('ALTER TABLE consent DROP attribute_stable'); + } +} diff --git a/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php b/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php index 709ba9a7e7..38b4af94de 100644 --- a/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php +++ b/src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php @@ -19,6 +19,10 @@ namespace OpenConext\EngineBlock\Authentication\Repository; use OpenConext\EngineBlock\Authentication\Model\Consent; +use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery; +use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters; +use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; interface ConsentRepository { @@ -37,4 +41,22 @@ public function findAllFor($userId); public function deleteAllFor($userId); public function deleteOneFor(string $userId, string $serviceProviderEntityId): bool; + + /** + * Test if the consent row is set with an attribute hash either stable or unstable + */ + public function hasConsentHash(ConsentHashQuery $query): ConsentVersion; + + /** + * By default stores the stable consent hash. The legacy consent hash is left. + */ + public function storeConsentHash(ConsentStoreParameters $parameters): bool; + + /** + * When a deprecated unstable consent hash is encoutered, we upgrade it to the new format using this + * update consent hash method. + */ + public function updateConsentHash(ConsentUpdateParameters $parameters): bool; + + public function countTotalConsent(string $consentUid): int; } diff --git a/src/OpenConext/EngineBlock/Authentication/Value/ConsentHashQuery.php b/src/OpenConext/EngineBlock/Authentication/Value/ConsentHashQuery.php new file mode 100644 index 0000000000..2e93727e83 --- /dev/null +++ b/src/OpenConext/EngineBlock/Authentication/Value/ConsentHashQuery.php @@ -0,0 +1,31 @@ +consentVersion = $consentVersion; + } + + public function given(): bool + { + return $this->consentVersion !== self::NOT_GIVEN; + } + + /** + * @return string + */ + public function __toString() + { + return $this->consentVersion; + } + + public function isUnstable(): bool + { + return $this->consentVersion === self::UNSTABLE; + } + + public function isStable(): bool + { + return $this->consentVersion === self::STABLE; + } +} diff --git a/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php new file mode 100644 index 0000000000..043f8a6ee4 --- /dev/null +++ b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashService.php @@ -0,0 +1,254 @@ +consentRepository = $consentHashRepository; + $this->featureConfiguration = $featureConfiguration; + } + + public function retrieveConsentHash(ConsentHashQuery $query): ConsentVersion + { + return $this->consentRepository->hasConsentHash($query); + } + + public function storeConsentHash(ConsentStoreParameters $parameters): bool + { + $migrationEnabled = $this->featureConfiguration->isEnabled(self::FEATURE_MIGRATION); + + if ($migrationEnabled) { + $parameters = new ConsentStoreParameters( + hashedUserId: $parameters->hashedUserId, + serviceId: $parameters->serviceId, + attributeStableHash: $parameters->attributeStableHash, + consentType: $parameters->consentType, + attributeHash: null, + ); + } + + return $this->consentRepository->storeConsentHash($parameters); + } + + public function updateConsentHash(ConsentUpdateParameters $parameters): bool + { + $migrationEnabled = $this->featureConfiguration->isEnabled(self::FEATURE_MIGRATION); + + if ($migrationEnabled) { + $parameters = new ConsentUpdateParameters( + attributeStableHash: $parameters->attributeStableHash, + attributeHash: $parameters->attributeHash, + hashedUserId: $parameters->hashedUserId, + serviceId: $parameters->serviceId, + consentType: $parameters->consentType, + clearLegacyHash: true, + ); + } + + return $this->consentRepository->updateConsentHash($parameters); + } + + public function countTotalConsent(string $consentUid): int + { + return $this->consentRepository->countTotalConsent($consentUid); + } + + /** + * The old way of calculating the attribute hash, this is not stable as a change of the attribute order, + * change of case, stray/empty attributes, and renumbered indexes can cause the hash to change. Leaving the + * user to give consent once again for a service she previously gave consent for. + */ + public function getUnstableAttributesHash(array $attributes, bool $mustStoreValues): string + { + if ($mustStoreValues) { + ksort($attributes); + $hashBase = serialize($attributes); + } else { + $names = array_keys($attributes); + sort($names); + $hashBase = implode('|', $names); + } + return sha1($hashBase); + } + + public function getStableAttributesHash(array $attributes, bool $mustStoreValues) : string + { + $nameIdNormalizedAttributes = $this->nameIdNormalize($attributes); + $lowerCasedAttributes = $this->caseNormalizeStringArray($nameIdNormalizedAttributes); + $hashBase = $mustStoreValues + ? $this->createHashBaseWithValues($lowerCasedAttributes) + : $this->createHashBaseWithoutValues($lowerCasedAttributes); + + return sha1($hashBase); + } + + private function createHashBaseWithValues(array $lowerCasedAttributes): string + { + return serialize($this->sortArray($lowerCasedAttributes)); + } + + private function createHashBaseWithoutValues(array $lowerCasedAttributes): string + { + $noEmptyAttributes = $this->removeEmptyAttributes($lowerCasedAttributes); + $sortedAttributes = $this->sortArray(array_keys($noEmptyAttributes)); + return implode('|', $sortedAttributes); + } + + /** + * Lowercases all array keys and string values recursively using mb_strtolower + * to handle multi-byte UTF-8 characters (e.g. Ü→ü, Arabic, Chinese — common in SAML). + * + * The previous implementation used serialize/strtolower/unserialize which corrupted + * PHP's s:N: byte-length markers for multi-byte values, causing unserialize() to silently + * return false and producing wrong hashes for any user with a non-ASCII attribute value. + */ + private function caseNormalizeStringArray(array $original): array + { + $result = []; + foreach ($original as $key => $value) { + $normalizedKey = is_string($key) ? mb_strtolower($key) : $key; + if (is_array($value)) { + $result[$normalizedKey] = $this->caseNormalizeStringArray($value); + } elseif (is_string($value)) { + $result[$normalizedKey] = mb_strtolower($value); + } else { + $result[$normalizedKey] = $value; + } + } + return $result; + } + + /** + * Recursively sorts an array via the ksort function. Performs the sort on a copy to avoid side-effects. + */ + private function sortArray(array $sortMe): array + { + $copy = unserialize(serialize($sortMe)); + $sortFunction = 'ksort'; + $copy = $this->removeEmptyAttributes($copy); + + if ($this->isSequentialArray($copy)) { + $sortFunction = 'sort'; + $copy = $this->renumberIndices($copy); + } + + $sortFunction($copy); + foreach ($copy as $key => $value) { + if (is_array($value)) { + $copy[$key] = $this->sortArray($value); + } + } + + return $copy; + } + + /** + * Determines whether an array is sequential, by checking to see if there's at no string keys in it. + */ + private function isSequentialArray(array $array): bool + { + return count(array_filter(array_keys($array), 'is_string')) === 0; + } + + /** + * Reindexes the values of the array so that any skipped numeric indexes are removed. + */ + private function renumberIndices(array $array): array + { + return array_values($array); + } + + /** + * Iterate over an array and unset any empty values. + */ + private function removeEmptyAttributes(array $array): array + { + $copy = unserialize(serialize($array)); + + foreach ($copy as $key => $value) { + if ($this->isBlank($value)) { + unset($copy[$key]); + } + } + + return $copy; + } + + /** + * Checks if a value is empty, but allowing 0 as an integer, float and string. This means the following are allowed: + * - 0 + * - 0.0 + * - "0" + * @param $value array|string|integer|float + */ + private function isBlank($value): bool + { + return empty($value) && !is_numeric($value); + } + + /** + * NameId objects can not be serialized/unserialized after being lower cased + * Thats why the object is converted to a simple array representation where only the + * relevant NameID aspects are stored. + */ + private function nameIdNormalize(array $attributes): array + { + array_walk_recursive($attributes, function (&$value) { + if ($value instanceof NameID) { + $value = ['value' => $value->getValue(), 'Format' => $value->getFormat()]; + } + }); + return $attributes; + } +} diff --git a/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php new file mode 100644 index 0000000000..520eb5229e --- /dev/null +++ b/src/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceInterface.php @@ -0,0 +1,42 @@ +consentRepository->findAllFor($userId); + return $this->consentRepository->countTotalConsent($userId); } catch (Exception $e) { throw new RuntimeException( - sprintf('An exception occurred while fetching consents the user has given ("%s")', $e->getMessage()), + sprintf('An exception occurred while counting consents the user has given ("%s")', $e->getMessage()), 0, $e ); } - - return count($consents); } public function deleteOneConsentFor(CollabPersonId $id, string $serviceProviderEntityId): bool diff --git a/src/OpenConext/EngineBlock/Service/ConsentServiceInterface.php b/src/OpenConext/EngineBlock/Service/Consent/ConsentServiceInterface.php similarity index 95% rename from src/OpenConext/EngineBlock/Service/ConsentServiceInterface.php rename to src/OpenConext/EngineBlock/Service/Consent/ConsentServiceInterface.php index 4ebb10c832..32f800ec9a 100644 --- a/src/OpenConext/EngineBlock/Service/ConsentServiceInterface.php +++ b/src/OpenConext/EngineBlock/Service/Consent/ConsentServiceInterface.php @@ -16,7 +16,7 @@ * limitations under the License. */ -namespace OpenConext\EngineBlock\Service; +namespace OpenConext\EngineBlock\Service\Consent; use OpenConext\EngineBlock\Authentication\Dto\ConsentList; use OpenConext\EngineBlock\Authentication\Value\CollabPersonId; diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Entity/Consent.php b/src/OpenConext/EngineBlockBundle/Authentication/Entity/Consent.php index 4398e85f97..d6f43378bc 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Entity/Consent.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Entity/Consent.php @@ -56,9 +56,15 @@ class Consent /** * @var string */ - #[ORM\Column(type: Types::STRING, length: 80)] + #[ORM\Column(type: Types::STRING, length: 80, nullable: true)] public ?string $attribute = null; + /** + * @var string + */ + #[ORM\Column(name: 'attribute_stable', type: Types::STRING, length: 80, nullable: true)] + public ?string $attributeStable = null; + /** * @var string */ diff --git a/src/OpenConext/EngineBlockBundle/Authentication/Repository/DbalConsentRepository.php b/src/OpenConext/EngineBlockBundle/Authentication/Repository/DbalConsentRepository.php index 2d422981f4..edbdac5135 100644 --- a/src/OpenConext/EngineBlockBundle/Authentication/Repository/DbalConsentRepository.php +++ b/src/OpenConext/EngineBlockBundle/Authentication/Repository/DbalConsentRepository.php @@ -25,7 +25,11 @@ use Doctrine\Persistence\ManagerRegistry; use OpenConext\EngineBlock\Authentication\Model\Consent; use OpenConext\EngineBlock\Authentication\Repository\ConsentRepository; +use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery; +use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters; use OpenConext\EngineBlock\Authentication\Value\ConsentType; +use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Exception\RuntimeException; use Psr\Log\LoggerInterface; use function sha1; @@ -64,12 +68,13 @@ public function findAllFor($userId) { // deleted_at IS NULL matches active records whose deleted_at is '0000-00-00 00:00:00'. // See Consent::$deletedAt for full context. - $sql = ' + $sql = ' SELECT service_id , consent_date , consent_type , attribute + , attribute_stable FROM consent WHERE @@ -92,7 +97,7 @@ function (array $row) use ($userId) { $row['service_id'], new DateTime($row['consent_date']), new ConsentType($row['consent_type']), - $row['attribute'] + $row['attribute_stable'] ?? $row['attribute'] ); }, $rows @@ -138,7 +143,8 @@ public function deleteOneFor(string $userId, string $serviceProviderEntityId): b hashed_user_id = :hashed_user_id AND service_id = :service_id - AND deleted_at IS NULL + AND + deleted_at IS NULL '; try { $result = $this->connection->executeQuery( @@ -170,4 +176,177 @@ public function deleteOneFor(string $userId, string $serviceProviderEntityId): b ); } } + + /** + * @throws RuntimeException + */ + public function hasConsentHash(ConsentHashQuery $query): ConsentVersion + { + try { + $sql = " SELECT + attribute_stable + FROM + consent + WHERE + hashed_user_id = ? + AND + service_id = ? + AND + (attribute = ? OR attribute_stable = ?) + AND + consent_type = ? + AND + deleted_at IS NULL + "; + + $rows = $this->connection->executeQuery($sql, [ + $query->hashedUserId, + $query->serviceId, + $query->attributeHash, + $query->attributeStableHash, + $query->consentType, + ])->fetchAllAssociative(); + + if (count($rows) < 1) { + // No stored consent found + return ConsentVersion::notGiven(); + } + + if (!empty($rows[0]['attribute_stable'])) { + return ConsentVersion::stable(); + } + return ConsentVersion::unstable(); + } catch (Exception $e) { + throw new RuntimeException(sprintf('Consent retrieval failed! Error: "%s"', $e->getMessage())); + } + } + + /** + * @throws RuntimeException + */ + public function storeConsentHash(ConsentStoreParameters $parameters): bool + { + if ($parameters->attributeHash !== null) { + $query = "INSERT INTO consent (hashed_user_id, service_id, attribute, attribute_stable, consent_type, consent_date, deleted_at) + VALUES (?, ?, ?, ?, ?, NOW(), '0000-00-00 00:00:00') + ON DUPLICATE KEY UPDATE attribute=VALUES(attribute), attribute_stable=VALUES(attribute_stable), + consent_type=VALUES(consent_type), consent_date=NOW(), deleted_at='0000-00-00 00:00:00'"; + $bindings = [ + $parameters->hashedUserId, + $parameters->serviceId, + $parameters->attributeHash, + $parameters->attributeStableHash, + $parameters->consentType, + ]; + } else { + $query = "INSERT INTO consent (hashed_user_id, service_id, attribute_stable, consent_type, consent_date, deleted_at) + VALUES (?, ?, ?, ?, NOW(), '0000-00-00 00:00:00') + ON DUPLICATE KEY UPDATE attribute_stable=VALUES(attribute_stable), + consent_type=VALUES(consent_type), consent_date=NOW(), deleted_at='0000-00-00 00:00:00'"; + $bindings = [ + $parameters->hashedUserId, + $parameters->serviceId, + $parameters->attributeStableHash, + $parameters->consentType, + ]; + } + + try { + $this->connection->executeStatement($query, $bindings); + } catch (Exception $e) { + throw new RuntimeException( + sprintf('Error storing consent: "%s"', $e->getMessage()) + ); + } + + return true; + } + + /** + * @throws RuntimeException + */ + public function updateConsentHash(ConsentUpdateParameters $parameters): bool + { + if ($parameters->clearLegacyHash) { + $query = " + UPDATE + consent + SET + attribute_stable = ?, + attribute = NULL + WHERE + attribute = ? + AND + hashed_user_id = ? + AND + service_id = ? + AND + consent_type = ? + AND + deleted_at IS NULL + "; + } else { + $query = " + UPDATE + consent + SET + attribute_stable = ? + WHERE + attribute = ? + AND + hashed_user_id = ? + AND + service_id = ? + AND + consent_type = ? + AND + deleted_at IS NULL + "; + } + + try { + $affected = $this->connection->executeStatement($query, [ + $parameters->attributeStableHash, + $parameters->attributeHash, + $parameters->hashedUserId, + $parameters->serviceId, + $parameters->consentType, + ]); + } catch (Exception $e) { + throw new RuntimeException( + sprintf('Error storing updated consent: "%s"', $e->getMessage()) + ); + } + + if ($affected === 0) { + $this->logger->warning( + sprintf( + 'Could not upgrade unstable consent hash for user "%s" and service "%s": no matching row found. ' . + 'The user\'s attributes may have changed since consent was given.', + $parameters->hashedUserId, + $parameters->serviceId + ) + ); + return false; + } + + return true; + } + + /** + * @throws RuntimeException + */ + public function countTotalConsent(string $consentUid): int + { + $query = "SELECT COUNT(*) FROM consent where hashed_user_id = ? AND deleted_at IS NULL"; + $parameters = [sha1($consentUid)]; + + try { + return (int) $this->connection->executeQuery($query, $parameters)->fetchOne(); + } catch (Exception $e) { + throw new RuntimeException( + sprintf('Error counting consent: "%s"', $e->getMessage()) + ); + } + } } diff --git a/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php b/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php index 4ef5e0d0a1..fa38378ea7 100644 --- a/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php +++ b/src/OpenConext/EngineBlockBundle/Controller/Api/ConsentController.php @@ -19,7 +19,7 @@ namespace OpenConext\EngineBlockBundle\Controller\Api; use OpenConext\EngineBlock\Exception\RuntimeException; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface; use OpenConext\EngineBlockBundle\Factory\CollabPersonIdFactory; use OpenConext\EngineBlockBundle\Http\Exception\ApiAccessDeniedHttpException; diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php index 399632ea5c..525d4d5b86 100644 --- a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/ConsentControllerTest.php @@ -527,6 +527,7 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent 'hashed_user_id' => ':user_id', 'service_id' => ':service_id', 'attribute' => ':attribute', + 'attribute_stable' => ':attribute_stable', 'consent_type' => ':consent_type', 'consent_date' => ':consent_date', 'deleted_at' => ':deleted_at', @@ -534,7 +535,8 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent ->setParameters([ 'user_id' => sha1($userId), 'service_id' => $serviceId, - 'attribute' => $attributeHash, + 'attribute' => '', + 'attribute_stable' => $attributeHash, 'consent_type' => $consentType, 'consent_date' => $consentDate, 'deleted_at' => $deletedAt, diff --git a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php index f2deb08f00..d9a046755d 100644 --- a/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php +++ b/tests/functional/OpenConext/EngineBlockBundle/Controller/Api/DeprovisionControllerTest.php @@ -407,6 +407,7 @@ private function addConsentFixture($userId, $serviceId, $attributeHash, $consent 'hashed_user_id' => ':user_id', 'service_id' => ':service_id', 'attribute' => ':attribute', + 'attribute_stable' => ':attribute', 'consent_type' => ':consent_type', 'consent_date' => ':consent_date', 'deleted_at' => '"0000-00-00 00:00:00"', diff --git a/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php b/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php new file mode 100644 index 0000000000..e979ddbd7a --- /dev/null +++ b/tests/library/EngineBlock/Test/Corto/Model/ConsentIntegrationTest.php @@ -0,0 +1,360 @@ +setDefaultMatcher(ConsentHashQuery::class, \Mockery\Matcher\IsEqual::class); + Mockery::getConfiguration()->setDefaultMatcher(ConsentStoreParameters::class, \Mockery\Matcher\IsEqual::class); + Mockery::getConfiguration()->setDefaultMatcher(ConsentUpdateParameters::class, \Mockery\Matcher\IsEqual::class); + + $this->response = Mockery::mock(EngineBlock_Saml2_ResponseAnnotationDecorator::class); + $this->consentRepository = Mockery::mock(ConsentRepository::class); + + $this->buildConsentAndService(migrationEnabled: true); + } + + /** + * Rebuilds $this->consentService and $this->consent with the given toggle state. + * Call this in tests that need a specific toggle setting different from setUp's default. + */ + private function buildConsentAndService(bool $migrationEnabled): void + { + $featureConfig = new FeatureConfiguration([ + 'eb.stable_consent_hash_migration' => $migrationEnabled, + ]); + $this->consentService = new ConsentHashService($this->consentRepository, $featureConfig); + $this->consent = new EngineBlock_Corto_Model_Consent( + "consent", + true, + $this->response, + [], + false, + true, + $this->consentService + ); + } + + #[DataProvider('consentTypeProvider')] + public function test_no_previous_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // No consent is given previously + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->once() + ->andReturn(ConsentVersion::notGiven()); + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertFalse($this->consent->explicitConsentWasGivenFor($serviceProvider)->given()); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertFalse($this->consent->implicitConsentWasGivenFor($serviceProvider)->given()); + break; + } + } + + #[DataProvider('consentTypeProvider')] + public function test_unstable_previous_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Stable consent is not yet stored + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(new ConsentHashQuery( + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + attributeHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + consentType: $consentType, + )) + ->once() + ->andReturn(ConsentVersion::unstable()); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->explicitConsentWasGivenFor($serviceProvider)->given()); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->implicitConsentWasGivenFor($serviceProvider)->given()); + break; + } + } + + #[DataProvider('consentTypeProvider')] + public function test_stable_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // Stable consent is not yet stored + $this->consentRepository + ->shouldReceive('hasConsentHash') + ->with(new ConsentHashQuery( + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + attributeHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + consentType: $consentType, + )) + ->once() + ->andReturn(ConsentVersion::stable()); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->explicitConsentWasGivenFor($serviceProvider)->given()); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->implicitConsentWasGivenFor($serviceProvider)->given()); + break; + } + } + + /** + * Toggle ON (migration enabled): new consent stores only the stable hash. + * The legacy attribute column must be left NULL so fully-migrated deployments + * don't accumulate unnecessary data in the old column. + */ + #[DataProvider('consentTypeProvider')] + public function test_give_consent_toggle_on_stores_only_stable_hash($consentType) + { + // setUp already builds with migrationEnabled=true + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + $this->consentRepository + ->shouldReceive('storeConsentHash') + ->once() + ->with(new ConsentStoreParameters( + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + consentType: $consentType, + attributeHash: null, + )) + ->andReturn(true); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->giveExplicitConsentFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->giveImplicitConsentFor($serviceProvider)); + break; + } + } + + /** + * Toggle OFF (migration disabled): new consent stores BOTH hashes so that + * old EB instances (still reading only the `attribute` column) can still + * find the consent record during a rolling deploy. + */ + #[DataProvider('consentTypeProvider')] + public function test_give_consent_toggle_off_stores_both_hashes($consentType) + { + $this->buildConsentAndService(migrationEnabled: false); + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + $this->consentRepository + ->shouldReceive('storeConsentHash') + ->once() + ->with(new ConsentStoreParameters( + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + consentType: $consentType, + attributeHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + )) + ->andReturn(true); + + switch ($consentType) { + case ConsentType::TYPE_EXPLICIT: + $this->assertTrue($this->consent->giveExplicitConsentFor($serviceProvider)); + break; + case ConsentType::TYPE_IMPLICIT: + $this->assertTrue($this->consent->giveImplicitConsentFor($serviceProvider)); + break; + } + } + + /** + * Toggle OFF (migration disabled): upgrading an old unstable consent leaves + * the legacy `attribute` column intact so old instances keep working. + */ + #[DataProvider('consentTypeProvider')] + public function test_upgrade_toggle_off_preserves_legacy_hash($consentType) + { + $this->buildConsentAndService(migrationEnabled: false); + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + $this->consentRepository + ->shouldReceive('updateConsentHash') + ->once() + ->with(new ConsentUpdateParameters( + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + attributeHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + consentType: $consentType, + clearLegacyHash: false, + )) + ->andReturn(true); + + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType, ConsentVersion::unstable())); + } + + /** + * Toggle ON (migration enabled): upgrading an old unstable consent nulls the + * legacy `attribute` column so the old column is cleaned up over time. + */ + #[DataProvider('consentTypeProvider')] + public function test_upgrade_toggle_on_clears_legacy_hash($consentType) + { + // setUp already builds with migrationEnabled=true + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + $this->consentRepository + ->shouldReceive('updateConsentHash') + ->once() + ->with(new ConsentUpdateParameters( + attributeStableHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + attributeHash: '8739602554c7f3241958e3cc9b57fdecb474d508', + hashedUserId: '0e54805079c56c2b1c1197a760af86ac337b7bac', + serviceId: 'service-provider-entity-id', + consentType: $consentType, + clearLegacyHash: true, + )) + ->andReturn(true); + + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType, ConsentVersion::unstable())); + } + + #[DataProvider('consentTypeProvider')] + public function test_upgrade_to_stable_consent_not_applied_when_stable($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + // No DB calls expected — stable consent does not trigger an update + $this->consentRepository->shouldNotReceive('hasConsentHash'); + $this->consentRepository->shouldNotReceive('storeConsentHash'); + $this->consentRepository->shouldNotReceive('updateConsentHash'); + + // Pass the pre-fetched ConsentVersion (stable) — no second DB query is made, no update triggered + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType, ConsentVersion::stable())); + } + + #[DataProvider('consentTypeProvider')] + public function test_upgrade_not_applied_when_no_consent_given($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + // No DB calls expected — no consent means nothing to upgrade + $this->consentRepository->shouldNotReceive('hasConsentHash'); + $this->consentRepository->shouldNotReceive('updateConsentHash'); + + // Pass the pre-fetched ConsentVersion (notGiven) — no update should be triggered + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType, ConsentVersion::notGiven())); + } + + #[DataProvider('consentTypeProvider')] + public function test_upgrade_continues_gracefully_when_attributes_changed($consentType) + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + $this->response->shouldReceive('getNameIdValue') + ->once() + ->andReturn('collab:person:id:org-a:joe-a'); + // But the UPDATE matches 0 rows (attributes changed since consent was given) + $this->consentRepository + ->shouldReceive('updateConsentHash') + ->once() + ->andReturn(false); + + // Must not throw; the warning is logged inside the repository + // Pass the pre-fetched ConsentVersion (unstable) — no second DB query is made + $this->assertNull($this->consent->upgradeAttributeHashFor($serviceProvider, $consentType, ConsentVersion::unstable())); + } + + public function test_store_consent_hash_sql_resets_deleted_at_on_duplicate(): void + { + // The storeConsentHash SQL must reset deleted_at='0000-00-00 00:00:00' in the + // ON DUPLICATE KEY UPDATE clause so soft-deleted rows become active again. + // We verify this by checking the SQL string directly. + new \ReflectionClass(DbalConsentRepository::class); + + // Read the SQL from the source to verify it contains the deleted_at reset + // This is a documentation test — if the SQL is refactored, update it here too. + $source = file_get_contents( + __DIR__ . '/../../../../../../src/OpenConext/EngineBlockBundle/Authentication/Repository/DbalConsentRepository.php' + ); + $this->assertStringContainsString( + "deleted_at='0000-00-00 00:00:00'", + $source, + 'ON DUPLICATE KEY UPDATE must reset deleted_at so soft-deleted re-consent rows become active' + ); + } + + public static function consentTypeProvider(): iterable + { + yield [ConsentType::TYPE_IMPLICIT]; + yield [ConsentType::TYPE_EXPLICIT]; + } +} diff --git a/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php b/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php index 47f34bf9ec..8cd8a7f022 100644 --- a/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Model/ConsentTest.php @@ -16,28 +16,36 @@ * limitations under the License. */ +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use OpenConext\EngineBlock\Authentication\Value\ConsentType; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; +use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface; use PHPUnit\Framework\TestCase; class EngineBlock_Corto_Model_ConsentTest extends TestCase { + use MockeryPHPUnitIntegration; + private $consentDisabled; private $consent; - private $mockedDatabaseConnection; + private $consentService; public function setUp(): void { - $this->mockedDatabaseConnection = Phake::mock('EngineBlock_Database_ConnectionFactory'); $mockedResponse = Phake::mock('EngineBlock_Saml2_ResponseAnnotationDecorator'); + Phake::when($mockedResponse)->getNameIdValue()->thenReturn('urn:collab:person:example.org:user1'); + + $this->consentService = Mockery::mock(ConsentHashServiceInterface::class); $this->consentDisabled = new EngineBlock_Corto_Model_Consent( "consent", true, $mockedResponse, [], - $this->mockedDatabaseConnection, false, - false + false, + $this->consentService ); $this->consent = new EngineBlock_Corto_Model_Consent( @@ -45,31 +53,128 @@ public function setUp(): void true, $mockedResponse, [], - $this->mockedDatabaseConnection, false, - true + true, + $this->consentService ); } public function testConsentDisabledDoesNotWriteToDatabase() { $serviceProvider = new ServiceProvider("service-provider-entity-id"); - $this->consentDisabled->explicitConsentWasGivenFor($serviceProvider); - $this->consentDisabled->implicitConsentWasGivenFor($serviceProvider); - $this->consentDisabled->giveExplicitConsentFor($serviceProvider); - $this->consentDisabled->giveImplicitConsentFor($serviceProvider); - Phake::verify($this->mockedDatabaseConnection, Phake::times(0))->create(); + $this->consentService->shouldNotReceive('storeConsentHash'); + $this->consentService->shouldNotReceive('retrieveConsentHash'); + $this->consentService->shouldNotReceive('updateConsentHash'); + + $this->assertTrue($this->consentDisabled->explicitConsentWasGivenFor($serviceProvider)->given()); + $this->assertTrue($this->consentDisabled->implicitConsentWasGivenFor($serviceProvider)->given()); + $this->assertTrue($this->consentDisabled->giveExplicitConsentFor($serviceProvider)); + $this->assertTrue($this->consentDisabled->giveImplicitConsentFor($serviceProvider)); + } + + public function testUpgradeAttributeHashSkippedWhenConsentDisabled() + { + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + + $this->consentService->shouldNotReceive('retrieveConsentHash'); + $this->consentService->shouldNotReceive('updateConsentHash'); + + $this->consentDisabled->upgradeAttributeHashFor($serviceProvider, ConsentType::TYPE_EXPLICIT, ConsentVersion::stable()); + $this->consentDisabled->upgradeAttributeHashFor($serviceProvider, ConsentType::TYPE_IMPLICIT, ConsentVersion::stable()); } public function testConsentWriteToDatabase() { $serviceProvider = new ServiceProvider("service-provider-entity-id"); - $this->consent->explicitConsentWasGivenFor($serviceProvider); - $this->consent->implicitConsentWasGivenFor($serviceProvider); - $this->consent->giveExplicitConsentFor($serviceProvider); - $this->consent->giveImplicitConsentFor($serviceProvider); - Phake::verify($this->mockedDatabaseConnection, Phake::times(4))->create(); + $this->consentService->shouldReceive('getUnstableAttributesHash')->andReturn(sha1('unstable')); + $this->consentService->shouldReceive('getStableAttributesHash')->andReturn(sha1('stable')); + $this->consentService->shouldReceive('retrieveConsentHash')->andReturn(ConsentVersion::stable()); + $this->consentService->shouldReceive('storeConsentHash')->andReturn(true); + + $this->assertTrue($this->consent->explicitConsentWasGivenFor($serviceProvider)->given()); + $this->assertTrue($this->consent->implicitConsentWasGivenFor($serviceProvider)->given()); + $this->assertTrue($this->consent->giveExplicitConsentFor($serviceProvider)); + $this->assertTrue($this->consent->giveImplicitConsentFor($serviceProvider)); + } + + public function testCountTotalConsent() + { + // Arrange + $this->consentService->shouldReceive('countTotalConsent') + ->with('urn:collab:person:example.org:user1') + ->once() + ->andReturn(5); + + // Act + Assert + $this->assertEquals(5, $this->consent->countTotalConsent()); + } + + public function testConsentUidFromAmPriorToConsentEnabled() + { + // When amPriorToConsentEnabled is true the consent UID must come from + // getOriginalResponse()->getCollabPersonId(), NOT from getNameIdValue(). + $originalResponse = Phake::mock('EngineBlock_Saml2_ResponseAnnotationDecorator'); + Phake::when($originalResponse)->getCollabPersonId()->thenReturn('urn:collab:person:example.org:user-am'); + + $mockedResponse = Phake::mock('EngineBlock_Saml2_ResponseAnnotationDecorator'); + Phake::when($mockedResponse)->getOriginalResponse()->thenReturn($originalResponse); + + $consentWithAmPrior = new EngineBlock_Corto_Model_Consent( + 'consent', + true, + $mockedResponse, + [], + true, // amPriorToConsentEnabled = true + true, + $this->consentService + ); + + $serviceProvider = new ServiceProvider('service-provider-entity-id'); + + $this->consentService->shouldReceive('getUnstableAttributesHash')->andReturn(sha1('unstable')); + $this->consentService->shouldReceive('getStableAttributesHash')->andReturn(sha1('stable')); + $this->consentService->shouldReceive('retrieveConsentHash')->andReturn(ConsentVersion::stable()); + + // Act: trigger a code path that calls _getConsentUid() + $result = $consentWithAmPrior->explicitConsentWasGivenFor($serviceProvider); + + // Assert: consent check succeeded and the AM-prior uid path was used + $this->assertTrue($result->given()); + Phake::verify($originalResponse)->getCollabPersonId(); + Phake::verify($mockedResponse, Phake::never())->getNameIdValue(); + } + + public function testNullNameIdReturnsNoConsentWithoutCallingRepository() + { + $mockedResponse = Phake::mock('EngineBlock_Saml2_ResponseAnnotationDecorator'); + Phake::when($mockedResponse)->getNameIdValue()->thenReturn(null); + + $consentWithNullUid = new EngineBlock_Corto_Model_Consent( + "consent", + true, + $mockedResponse, + [], + false, + true, + $this->consentService + ); + + $serviceProvider = new ServiceProvider("service-provider-entity-id"); + + // No DB calls should occur when the NameID is null + $this->consentService->shouldNotReceive('retrieveConsentHash'); + $this->consentService->shouldNotReceive('storeConsentHash'); + $this->consentService->shouldNotReceive('updateConsentHash'); + + // _hasStoredConsent returns notGiven() when UID is null -> consent methods return false + $this->assertFalse($consentWithNullUid->explicitConsentWasGivenFor($serviceProvider)->given()); + $this->assertFalse($consentWithNullUid->implicitConsentWasGivenFor($serviceProvider)->given()); + // giveConsent returns false when UID is null (_storeConsent returns early) + $this->assertFalse($consentWithNullUid->giveExplicitConsentFor($serviceProvider)); + $this->assertFalse($consentWithNullUid->giveImplicitConsentFor($serviceProvider)); + // upgradeAttributeHashFor should not throw when UID is null + $consentWithNullUid->upgradeAttributeHashFor($serviceProvider, ConsentType::TYPE_EXPLICIT, ConsentVersion::notGiven()); } } diff --git a/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php b/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php index 18095329ff..190b7dff1f 100644 --- a/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Module/Service/ProcessConsentTest.php @@ -17,6 +17,7 @@ */ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Metadata\MetadataRepository\InMemoryMetadataRepository; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; @@ -148,6 +149,7 @@ public function testConsentIsStored() } public function testResponseIsSent() { + $this->mockConsent(); $processConsentService = $this->factoryService(); Phake::when($this->proxyServerMock) @@ -221,7 +223,7 @@ private function mockConsent() $consentMock = Phake::mock('EngineBlock_Corto_Model_Consent'); Phake::when($consentMock) ->explicitConsentWasGivenFor(Phake::anyParameters()) - ->thenReturn(false); + ->thenReturn(ConsentVersion::notGiven()); Phake::when($this->consentFactoryMock) ->create(Phake::anyParameters()) ->thenReturn($consentMock); diff --git a/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php b/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php index 10e78a8d7e..77bfb3f63c 100644 --- a/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php +++ b/tests/library/EngineBlock/Test/Corto/Module/Service/ProvideConsentTest.php @@ -17,13 +17,14 @@ */ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use OpenConext\EngineBlock\Authentication\Value\ConsentVersion; use OpenConext\EngineBlock\Metadata\Coins; use OpenConext\EngineBlock\Metadata\ConsentSettings; use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider; use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider; use OpenConext\EngineBlock\Metadata\MetadataRepository\InMemoryMetadataRepository; use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface; -use OpenConext\EngineBlock\Service\ConsentServiceInterface; +use OpenConext\EngineBlock\Service\Consent\ConsentServiceInterface; use OpenConext\EngineBlock\Service\Dto\ProcessingStateStep; use OpenConext\EngineBlock\Service\ProcessingStateHelper; use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface; @@ -123,7 +124,7 @@ public function testConsentIsSkippedWhenPriorConsentIsStored() Phake::when($this->consentMock) ->explicitConsentWasGivenFor(Phake::anyParameters()) - ->thenReturn(true); + ->thenReturn(ConsentVersion::stable()); $provideConsentService->serve(null, $this->httpRequestMock); @@ -279,7 +280,10 @@ private function mockConsent() $consentMock = Phake::mock('EngineBlock_Corto_Model_Consent'); Phake::when($consentMock) ->explicitConsentWasGivenFor(Phake::anyParameters()) - ->thenReturn(false); + ->thenReturn(ConsentVersion::notGiven()); + Phake::when($consentMock) + ->implicitConsentWasGivenFor(Phake::anyParameters()) + ->thenReturn(ConsentVersion::notGiven()); Phake::when($this->consentFactoryMock) ->create(Phake::anyParameters()) ->thenReturn($consentMock); diff --git a/tests/library/EngineBlock/Test/Saml2/NameIdResolverMock.php b/tests/library/EngineBlock/Test/Saml2/NameIdResolverMock.php index 3a4c614bde..05e70f620b 100644 --- a/tests/library/EngineBlock/Test/Saml2/NameIdResolverMock.php +++ b/tests/library/EngineBlock/Test/Saml2/NameIdResolverMock.php @@ -21,17 +21,9 @@ class EngineBlock_Test_Saml2_NameIdResolverMock extends EngineBlock_Saml2_NameId private $_serviceProviderUuids = array(); private $_persistentIds = array(); - protected function _getUserDirectory() + protected function _getUserUuid($collabPersonId) { - $mock = new EngineBlock_Test_UserDirectoryMock(); - $mock->setUser( - 'urn:collab:person:example.edu:mock1', - array( - 'collabpersonid' => 'urn:collab:person:example.edu:mock1', - 'collabpersonuuid' => '', - ) - ); - return $mock; + return sha1($collabPersonId); } protected function _fetchPersistentId($serviceProviderUuid, $userUuid) diff --git a/tests/library/EngineBlock/Test/Saml2/NameIdResolverTest.php b/tests/library/EngineBlock/Test/Saml2/NameIdResolverTest.php index 228d62336b..fa8760c3ee 100644 --- a/tests/library/EngineBlock/Test/Saml2/NameIdResolverTest.php +++ b/tests/library/EngineBlock/Test/Saml2/NameIdResolverTest.php @@ -162,8 +162,6 @@ public function testMetadataOverAuthnRequest() public function testPersistent(): void { - $this->markTestSkipped('Fails when switching to other backend, test should not rely on having fixed backend'); - // Input $nameIdFormat = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'; $this->serviceProvider->nameIdFormat = $nameIdFormat; diff --git a/tests/phpunit.xml b/tests/phpunit.xml index cca070da44..4e1bcf3fbe 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -17,35 +17,37 @@ failOnRisky="true" > - - unit - - - integration - - - library - - - functional - - - - - - - - - - - src - library - - - - - - - - + + unit + + + integration + + + library + + + functional + + + + + + + + + + + + src + library + + + + + + + + + diff --git a/tests/unit/OpenConext/EngineBlock/Authentication/Value/ConsentVersionTest.php b/tests/unit/OpenConext/EngineBlock/Authentication/Value/ConsentVersionTest.php new file mode 100644 index 0000000000..7c6f26e1c1 --- /dev/null +++ b/tests/unit/OpenConext/EngineBlock/Authentication/Value/ConsentVersionTest.php @@ -0,0 +1,62 @@ +assertTrue($version->given()); + $this->assertTrue($version->isStable()); + $this->assertFalse($version->isUnstable()); + $this->assertSame('stable', (string) $version); + } + + public function testUnstableIsGiven(): void + { + $version = ConsentVersion::unstable(); + + $this->assertTrue($version->given()); + $this->assertFalse($version->isStable()); + $this->assertTrue($version->isUnstable()); + $this->assertSame('unstable', (string) $version); + } + + public function testNotGivenIsNotGiven(): void + { + $version = ConsentVersion::notGiven(); + + $this->assertFalse($version->given()); + $this->assertFalse($version->isStable()); + $this->assertFalse($version->isUnstable()); + $this->assertSame('not-given', (string) $version); + } + + public function testInvalidVersionThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new ConsentVersion('invalid'); + } +} diff --git a/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php b/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php new file mode 100644 index 0000000000..5c051d4d7f --- /dev/null +++ b/tests/unit/OpenConext/EngineBlock/Service/Consent/ConsentHashServiceTest.php @@ -0,0 +1,641 @@ + false]); + $this->chs = new ConsentHashService($mockConsentHashRepository, $featureConfig); + } + + public function test_stable_attribute_hash_switched_order_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrder = [ + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrder, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrder, true)); + } + + public function test_stable_attribute_hash_switched_order_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrder = [ + ['John Doe'], + ['John Doe'], + ['joe-f12'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['example.com'], + ['j.doe@example.com'], + [ + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.ORG', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrder, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrder, true)); + } + + public function test_stable_attribute_hash_switched_order_and_different_casing_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrderAndCasing = [ + 'urn:mace:dir:attribute-def:sn' => ['DOE'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:CN' => ['John Doe'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-DEF:displayName' => ['John Doe'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + 'urn:mace:dir:attribute-def:UID' => ['joe-f12'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, true)); + } + + public function test_stable_attribute_hash_switched_order_and_different_casing_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab2:org:vm.openconext.ORG', + 'urn:collab3:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab1:org:vm.openconext.org', + 'urn:collab2:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.org', + ], + ]; + $attributesSwitchedOrderAndCasing = [ + ['joe-f12'], + ['John Doe'], + ['John Doe'], + ['j.doe@example.com'], + ['John'], + ['EXample.com'], + ['j.doe@example.com'], + [ + 'URN:collab2:org:vm.openconext.ORG', + 'urn:collab2:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.Org', + 'urn:collaboration:organisation:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.org', + 'urn:collab3:org:vm.openconext.ORG', + 'urn:collab1:org:vm.openconext.org', + ], + ['DOE'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSwitchedOrderAndCasing, true)); + } + + public function test_stable_attribute_hash_different_casing_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesDifferentCasing = [ + 'urn:mace:dir:attribute-def:DISPLAYNAME' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['DOE'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:ISMemberOf' => [ + 'URN:collab:org:VM.openconext.org', + 'URN:collab:org:VM.openconext.org', + 'URN:collab:org:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_different_casing_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesDifferentCasing = [ + ['JOHN Doe'], + ['joe-f12'], + ['John DOE'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + [ + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:VM.openconext.ORG', + 'urn:collab:org:VM.openconext.org', + 'urn:collaboration:organisation:VM.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:COLLAB:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_reordering_sparse_sequential_arrays() + { + $attributes = [ "AttributeA" => [ 0 => "aap", 1 => "noot"] ]; + $attributesDifferentCasing = + [ "AttributeA" => [ 0 => "aap", 2 => "noot"] ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentCasing, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentCasing, true)); + } + + public function test_stable_attribute_hash_remove_empty_attributes() + { + $attributes = [ "AttributeA" => [ 0 => "aap", 1 => "noot"], "AttributeB" => [], "AttributeC" => 0 ]; + $attributesDifferentNoEmptyValues = + [ "AttributeA" => [ 0 => "aap", 2 => "noot"], "AttributeC" => 0 ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesDifferentNoEmptyValues, false)); + $this->assertEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesDifferentNoEmptyValues, true)); + } + + public function test_stable_attribute_hash_two_different_arrays_yield_different_hashes_associative() + { + $attributes = [ + 'a' => ['John Doe'], + 'b' => ['joe-f12'], + 'c' => ['John Doe'], + 'd' => ['Doe'], + 'e' => ['j.doe@example.com'], + 'f' => ['John'], + 'g' => ['j.doe@example.com'], + 'h' => ['example.com'], + ]; + $differentAttributes = [ + 'i' => 'urn:collab:org:vm.openconext.ORG', + 'j' => 'urn:collab:org:vm.openconext.ORG', + 'k' => 'urn:collab:org:vm.openconext.ORG', + 'l' => 'urn:collab:org:vm.openconext.org', + 'm' => 'urn:collaboration:organisation:vm.openconext.org', + 'n' => 'urn:collab:org:vm.openconext.org', + 'o' => 'urn:collab:org:vm.openconext.org', + 'p' => 'urn:collab:org:vm.openconext.org', + ]; + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($differentAttributes, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($differentAttributes, true)); + } + + public function test_stable_attribute_hash_two_different_arrays_yield_different_hashes_sequential() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + ]; + $differentAttributes = [ + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ]; + // Known limitation: when mustStoreValues=false the hash is built from attribute *names* only. + // For sequential (numerically-indexed) arrays the "names" are just integer indices [0, 1, 2, …], + // so two sequential arrays with the same count but completely different values produce the same hash. + // This is accepted because in practice all SAML attributes are keyed by URN strings + // (e.g. 'urn:mace:dir:attribute-def:displayName'), not by integer indices, making this + // collision path unreachable in production. + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($differentAttributes, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($differentAttributes, true)); + } + + public function test_stable_attribute_hash_multiple_value_vs_single_value_associative_array() + { + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:ORG:vm.openconext.ORG', + 'urn:collab:org:vm.openconext.org', + 'urn:collaboration:organisation:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + 'urn:collab:org:vm.openconext.org', + ], + ]; + $attributesSingleValue = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'urn:mace:dir:attribute-def:isMemberOf' => [ + 'urn:collab:org:vm.openconext.org', + ], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSingleValue, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSingleValue, true)); + } + + public function test_stable_attribute_hash_multiple_value_vs_single_value_sequential_array() + { + $attributes = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com', 'j.doe@example.com', 'jane'], + ]; + $attributesSingleValue = [ + ['John Doe'], + ['joe-f12'], + ['John Doe'], + ['Doe'], + ['j.doe@example.com'], + ['John'], + ['j.doe@example.com'], + ['example.com'], + ]; + $this->assertEquals($this->chs->getStableAttributesHash($attributes, false), $this->chs->getStableAttributesHash($attributesSingleValue, false)); + $this->assertNotEquals($this->chs->getStableAttributesHash($attributes, true), $this->chs->getStableAttributesHash($attributesSingleValue, true)); + } + + public function test_stable_attribute_hash_can_handle_nameid_objects() + { + $nameId = new NameID(); + $nameId->setValue('83aa0a79363edcf872c966b0d6eaf3f5e26a6a77'); + $nameId->setFormat('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'); + + $attributes = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + 'urn:mace:dir:attribute-def:cn' => ['John Doe'], + 'urn:mace:dir:attribute-def:sn' => ['Doe'], + 'urn:mace:dir:attribute-def:eduPersonPrincipalName' => ['j.doe@example.com'], + 'urn:mace:dir:attribute-def:givenName' => ['John'], + 'urn:mace:dir:attribute-def:mail' => ['j.doe@example.com'], + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => ['example.com'], + 'nl:surf:test:something' => [0 => 'arbitrary-value'], + 'urn:mace:dir:attribute-def:eduPersonTargetedID' => [$nameId], + 'urn:oid:1.3.6.1.4.1.5923.1.1.1.10' => [$nameId], + ]; + + $hash = $this->chs->getStableAttributesHash($attributes, false); + $this->assertTrue(is_string($hash)); + } + + public function test_stable_attribute_hash_attribute_name_casing_normalized() + { + // Issue requirement: "Case normalize all attribute names" + // Attribute names (keys) differing only in casing must yield the same hash + $lowercase = [ + 'urn:mace:dir:attribute-def:displayname' => ['John Doe'], + 'urn:mace:dir:attribute-def:uid' => ['joe-f12'], + ]; + $mixed = [ + 'urn:mace:dir:attribute-def:displayName' => ['John Doe'], + 'URN:MACE:DIR:ATTRIBUTE-DEF:UID' => ['joe-f12'], + ]; + + $this->assertEquals( + $this->chs->getStableAttributesHash($lowercase, true), + $this->chs->getStableAttributesHash($mixed, true) + ); + $this->assertEquals( + $this->chs->getStableAttributesHash($lowercase, false), + $this->chs->getStableAttributesHash($mixed, false) + ); + } + + public function test_stable_attribute_hash_attribute_name_ordering_normalized() + { + // Issue requirement: "Sort all attribute names" + $alphabetical = [ + 'urn:attribute:a' => ['value1'], + 'urn:attribute:b' => ['value2'], + 'urn:attribute:c' => ['value3'], + ]; + $reversed = [ + 'urn:attribute:c' => ['value3'], + 'urn:attribute:b' => ['value2'], + 'urn:attribute:a' => ['value1'], + ]; + + $this->assertEquals( + $this->chs->getStableAttributesHash($alphabetical, true), + $this->chs->getStableAttributesHash($reversed, true) + ); + $this->assertEquals( + $this->chs->getStableAttributesHash($alphabetical, false), + $this->chs->getStableAttributesHash($reversed, false) + ); + } + + // ------------------------------------------------------------------------- + // Unstable hash algorithm — getUnstableAttributesHash + // ------------------------------------------------------------------------- + + public function test_unstable_attribute_hash_mustStoreValues_false_uses_keys_only() + { + // When mustStoreValues=false the hash is based on attribute names only. + // Two arrays with the same keys but different values must yield the same hash. + $attributes = ['urn:attr:a' => ['Alice'], 'urn:attr:b' => ['Bob']]; + $differentValues = ['urn:attr:a' => ['Charlie'], 'urn:attr:b' => ['Dave']]; + + $this->assertEquals( + $this->chs->getUnstableAttributesHash($attributes, false), + $this->chs->getUnstableAttributesHash($differentValues, false) + ); + } + + public function test_unstable_attribute_hash_mustStoreValues_true_includes_values() + { + // When mustStoreValues=true, attribute values are part of the hash. + // Two arrays with the same keys but different values must yield a different hash. + $attributes = ['urn:attr:a' => ['Alice'], 'urn:attr:b' => ['Bob']]; + $differentValues = ['urn:attr:a' => ['Charlie'], 'urn:attr:b' => ['Dave']]; + + $this->assertNotEquals( + $this->chs->getUnstableAttributesHash($attributes, true), + $this->chs->getUnstableAttributesHash($differentValues, true) + ); + } + + public function test_unstable_attribute_hash_key_order_normalized_in_names_only_mode() + { + // When mustStoreValues=false the implementation sorts attribute names, + // so reversed key order must produce the same hash. + $attributes = ['urn:attr:a' => ['Alice'], 'urn:attr:b' => ['Bob']]; + $reversed = ['urn:attr:b' => ['Bob'], 'urn:attr:a' => ['Alice']]; + + $this->assertEquals( + $this->chs->getUnstableAttributesHash($attributes, false), + $this->chs->getUnstableAttributesHash($reversed, false) + ); + } + + // ------------------------------------------------------------------------- + // isBlank / removeEmptyAttributes edge cases + // ------------------------------------------------------------------------- + + public function test_stable_attribute_hash_empty_array_produces_consistent_hash() + { + // An empty attribute array must not throw and must be idempotent. + $hashWithValues = $this->chs->getStableAttributesHash([], true); + $hashWithoutValues = $this->chs->getStableAttributesHash([], false); + + $this->assertIsString($hashWithValues); + $this->assertSame($hashWithValues, $this->chs->getStableAttributesHash([], true)); + $this->assertIsString($hashWithoutValues); + $this->assertSame($hashWithoutValues, $this->chs->getStableAttributesHash([], false)); + } + + public function test_stable_attribute_hash_zero_string_not_removed_as_empty() + { + // "0" is truthy via is_numeric(), so it must NOT be removed by removeEmptyAttributes. + // An attribute with value "0" must produce a different hash than an empty attribute set. + $withZeroString = ['urn:attr:count' => '0']; + $withoutAttr = []; + + $this->assertNotEquals( + $this->chs->getStableAttributesHash($withZeroString, true), + $this->chs->getStableAttributesHash($withoutAttr, true) + ); + } + + public function test_stable_attribute_hash_zero_float_not_removed_as_empty() + { + // 0.0 is numeric, so it must NOT be removed by removeEmptyAttributes. + // An attribute with value 0.0 must produce a stable, non-empty hash. + $withZeroFloat = ['urn:attr:count' => 0.0]; + + $hash = $this->chs->getStableAttributesHash($withZeroFloat, true); + + $this->assertIsString($hash); + $this->assertNotEmpty($hash); + // Must be idempotent + $this->assertSame($hash, $this->chs->getStableAttributesHash($withZeroFloat, true)); + } + + public function test_stable_attribute_hash_handles_multibyte_utf8_values(): void + { + // Arabic, Chinese, accented names — all common in European SAML federations + $attributes = [ + 'urn:mace:dir:attribute-def:sn' => ['Müller'], + 'urn:mace:dir:attribute-def:cn' => ['محمد'], + 'urn:mace:dir:attribute-def:displayName' => ['王芳'], + ]; + $hash = $this->chs->getStableAttributesHash($attributes, true); + $this->assertIsString($hash); + $this->assertEquals(40, strlen($hash), 'SHA1 hash must be 40 hex chars; a false return from unserialize produces a wrong hash'); + } + + public function test_stable_hash_is_case_insensitive_for_multibyte_strings(): void + { + $lower = ['urn:mace:dir:attribute-def:sn' => ['müller']]; + $upper = ['urn:mace:dir:attribute-def:sn' => ['Müller']]; + $this->assertEquals( + $this->chs->getStableAttributesHash($lower, true), + $this->chs->getStableAttributesHash($upper, true), + 'Stable hash must be case-insensitive for multi-byte characters' + ); + } +} diff --git a/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php b/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php index b68d0beb77..5318eccfc0 100644 --- a/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php +++ b/tests/unit/OpenConext/EngineBlock/Service/ConsentServiceTest.php @@ -24,6 +24,7 @@ use OpenConext\EngineBlock\Authentication\Repository\ConsentRepository; use OpenConext\EngineBlock\Authentication\Value\CollabPersonId; use OpenConext\EngineBlock\Authentication\Value\CollabPersonUuid; +use OpenConext\EngineBlock\Service\Consent\ConsentService; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test;