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;