diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a8476c503..0535df19ae 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,6 +26,16 @@ updates: ignore: - dependency-name: "typescript" update-types: ["version-update:semver-major"] + # vue-router 5 peers `vite: ^7.3.0 || ^8.0.0` and expects a Vite + # toolchain. Measured, not assumed, and the result is SPLIT: integriq and + # zaakafhandelapp fail their build on `Can't resolve 'vue-router'`, while + # openregister and learniq build clean on 5.3.0. Held because a bump that + # breaks some apps and not others cannot be merged unattended, and the + # difference is not yet understood. Adopting it fleet-wide is a + # Vite migration, not a bump. versioniq is already on Vite and is the + # natural pilot. + - dependency-name: "vue-router" + update-types: ["version-update:semver-major"] - dependency-name: "webpack-cli" update-types: ["version-update:semver-major"] - dependency-name: "@babel/core" diff --git a/appinfo/info.xml b/appinfo/info.xml index 8c82b5f68f..15deb5804d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -40,7 +40,7 @@ Open Register drijft apps zoals OpenCatalogi, Procest, Pipelinq en Software Cata Vrij en open source onder de EUPL-licentie. ]]> - 1.1.15-unstable.20260830153944 + 2.0.5-unstable.20260831053109 EUPL-1.2 Conduction OpenRegister diff --git a/composer.json b/composer.json index eb4da314e8..d1c459351f 100644 --- a/composer.json +++ b/composer.json @@ -109,7 +109,7 @@ "elasticsearch/elasticsearch": "^v9.5.0", "guzzlehttp/guzzle": "^7.0", "jwadhams/json-logic-php": "^1.5", - "minishlink/web-push": "^9.0", + "minishlink/web-push": "^11.0", "opis/json-schema": "^2.3", "phpoffice/phpspreadsheet": "^5.0", "phpoffice/phpword": "^1.2", diff --git a/composer.lock b/composer.lock index c1ce99d490..a9192b0f00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "330f182fcbbc729650e13937a5c12eab", + "content-hash": "e8d1e40030308144ae31c190012f7581", "packages": [ { "name": "adbario/php-dot-notation", @@ -119,16 +119,16 @@ }, { "name": "brick/math", - "version": "0.19.1", + "version": "0.20.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce" + "reference": "44d7e16e2e940d9c7dde7076598e25855026dba2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", - "reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", + "url": "https://api.github.com/repos/brick/math/zipball/44d7e16e2e940d9c7dde7076598e25855026dba2", + "reference": "44d7e16e2e940d9c7dde7076598e25855026dba2", "shasum": "" }, "require": { @@ -166,7 +166,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.19.1" + "source": "https://github.com/brick/math/tree/0.20.0" }, "funding": [ { @@ -174,7 +174,7 @@ "type": "github" } ], - "time": "2026-08-08T23:03:16+00:00" + "time": "2026-08-28T20:53:19+00:00" }, { "name": "composer/pcre", @@ -1445,16 +1445,16 @@ }, { "name": "minishlink/web-push", - "version": "v9.0.4", + "version": "v11.0.0", "source": { "type": "git", "url": "https://github.com/web-push-libs/web-push-php.git", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + "reference": "f8410afb73486ab895bb4792b879bdca62025b47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", - "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f8410afb73486ab895bb4792b879bdca62025b47", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47", "shasum": "" }, "require": { @@ -1462,21 +1462,33 @@ "ext-json": "*", "ext-mbstring": "*", "ext-openssl": "*", - "guzzlehttp/guzzle": "^7.9.2", - "php": ">=8.1", + "php": ">=8.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^2.0|^3.0", "spomky-labs/base64url": "^2.0.4", - "symfony/polyfill-php82": "^v1.31.0", - "web-token/jwt-library": "^3.3.0|^4.0.0" + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^v3.91.3", - "phpstan/phpstan": "^2.1.2", - "phpunit/phpunit": "^10.5.44|^11.5.6", + "friendsofphp/php-cs-fixer": "^v3.92.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", "symfony/polyfill-iconv": "^1.33" }, "suggest": { "ext-bcmath": "Optional for performance.", - "ext-gmp": "Optional for performance." + "ext-gmp": "Optional for performance.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." }, "type": "library", "autoload": { @@ -1506,9 +1518,9 @@ ], "support": { "issues": "https://github.com/web-push-libs/web-push-php/issues", - "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4" + "source": "https://github.com/web-push-libs/web-push-php/tree/v11.0.0" }, - "time": "2025-12-10T14:00:12+00:00" + "time": "2026-07-23T16:10:05+00:00" }, { "name": "nyholm/psr7", @@ -3513,20 +3525,20 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.6.0", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf" + "reference": "5d630ec371652229ad568337519f16e539e5f79d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/80778a25426288acd2e3a7cde2def41a3d59cddf", - "reference": "80778a25426288acd2e3a7cde2def41a3d59cddf", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/5d630ec371652229ad568337519f16e539e5f79d", + "reference": "5d630ec371652229ad568337519f16e539e5f79d", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", "ext-mbstring": "*", "php": ">=8.1" }, @@ -3606,7 +3618,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.0" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.1" }, "funding": [ { @@ -3618,7 +3630,7 @@ "type": "patreon" } ], - "time": "2026-08-06T16:21:11+00:00" + "time": "2026-08-30T13:53:49+00:00" }, { "name": "symfony/console", @@ -6002,23 +6014,24 @@ }, { "name": "web-token/jwt-library", - "version": "4.1.9", + "version": "4.2.2", "source": { "type": "git", "url": "https://github.com/web-token/jwt-library.git", - "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69" + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-token/jwt-library/zipball/cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", - "reference": "cbd52c8e96b835498f0f9c3a70a94d9f0e9e6c69", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/ae642340ee2ca91ca0c37edd72a9d06302651ca9", + "reference": "ae642340ee2ca91ca0c37edd72a9d06302651ca9", "shasum": "" }, "require": { - "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19", + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", "php": ">=8.2", "psr/clock": "^1.0", - "spomky-labs/pki-framework": "^1.2.1" + "spomky-labs/pki-framework": "^1.2.1", + "symfony/deprecation-contracts": "^2.5|^3.0" }, "conflict": { "spomky-labs/jose": "*" @@ -6075,7 +6088,7 @@ ], "support": { "issues": "https://github.com/web-token/jwt-library/issues", - "source": "https://github.com/web-token/jwt-library/tree/4.1.9" + "source": "https://github.com/web-token/jwt-library/tree/4.2.2" }, "funding": [ { @@ -6087,7 +6100,7 @@ "type": "patreon" } ], - "time": "2026-08-26T09:47:42+00:00" + "time": "2026-08-30T13:53:36+00:00" }, { "name": "webonyx/graphql-php", @@ -8925,21 +8938,21 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", + "phpcsstandards/phpcsutils": "^1.2.3", "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { @@ -9003,7 +9016,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", diff --git a/lib/BackgroundJob/TenantDeprovisionJob.php b/lib/BackgroundJob/TenantDeprovisionJob.php index 8c850438f3..db2ce31dbd 100644 --- a/lib/BackgroundJob/TenantDeprovisionJob.php +++ b/lib/BackgroundJob/TenantDeprovisionJob.php @@ -74,7 +74,10 @@ protected function run(mixed $argument): void { $this->logger->info('[TenantDeprovisionJob] Starting deprovisioning check'); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_DEPROVISIONING] ); } catch (\Exception $e) { diff --git a/lib/BackgroundJob/TenantPurgeJob.php b/lib/BackgroundJob/TenantPurgeJob.php index d978683a15..a9880e1636 100644 --- a/lib/BackgroundJob/TenantPurgeJob.php +++ b/lib/BackgroundJob/TenantPurgeJob.php @@ -92,7 +92,10 @@ protected function run(mixed $argument): void { $cutoffDate->sub(new DateInterval("P{$retentionDays}D")); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_ARCHIVED] ); } catch (\Exception $e) { diff --git a/lib/BackgroundJob/TenantUsageSyncJob.php b/lib/BackgroundJob/TenantUsageSyncJob.php index 3406310d3c..a11b70890b 100644 --- a/lib/BackgroundJob/TenantUsageSyncJob.php +++ b/lib/BackgroundJob/TenantUsageSyncJob.php @@ -82,7 +82,10 @@ protected function run(mixed $argument): void { $this->logger->debug('[TenantUsageSyncJob] Starting usage sync'); try { - $organisations = $this->organisationMapper->findAll( + // Tenant-scoped read, not findAll: a federated counterparty is an + // organisation in this table but NOT a tenant of this installation, and + // selecting on status alone would sweep it up with the tenants. + $organisations = $this->organisationMapper->findLocalTenants( filters: ['status' => TenantLifecycleService::STATUS_ACTIVE] ); } catch (\Exception $e) { diff --git a/lib/Db/MagicMapper/MagicOrganizationHandler.php b/lib/Db/MagicMapper/MagicOrganizationHandler.php index 91cfd930af..4d5cde125c 100644 --- a/lib/Db/MagicMapper/MagicOrganizationHandler.php +++ b/lib/Db/MagicMapper/MagicOrganizationHandler.php @@ -56,6 +56,36 @@ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class MagicOrganizationHandler { + + /** + * Every row is visible — a trusted system context, or an admin with the + * bypass enabled outside SaaS mode. + */ + public const SCOPE_ALL = 'all'; + + /** + * No row is visible — a non-admin with no active organisation. + */ + public const SCOPE_NONE = 'none'; + + /** + * Only rows with NO organisation — an admin with no active organisation. + */ + public const SCOPE_NULL_ONLY = 'null-only'; + + /** + * Rows in the caller's active organisation(s). + */ + public const SCOPE_IN = 'in'; + + /** + * Rows in the caller's active organisation(s), PLUS rows with no + * organisation at all. Admins only. This is the mode the aggregation API + * used to get wrong: it rendered only the `IN` half, and SQL `=` / `IN` + * never match NULL, so org-less rows vanished from every KPI. + */ + public const SCOPE_IN_OR_NULL = 'in-or-null'; + /** * Constructor for MagicOrganizationHandler. * @@ -96,25 +126,96 @@ public function applyOrganizationFilter( IQueryBuilder $qb, bool $adminBypassEnabled = false, ): void { + $scope = $this->resolveOrganizationScope(adminBypassEnabled: $adminBypassEnabled); + + if ($scope['mode'] === self::SCOPE_ALL) { + return; + } + + if ($scope['mode'] === self::SCOPE_NONE) { + $qb->andWhere('1 = 0'); + return; + } + + if ($scope['mode'] === self::SCOPE_NULL_ONLY) { + $qb->andWhere($qb->expr()->isNull('t._organisation')); + return; + } + + // Condition 1: objects belonging to the caller's active organisation(s). + $conditions = []; + $conditions[] = $qb->expr()->in( + 't._organisation', + $qb->createNamedParameter($scope['uuids'], IQueryBuilder::PARAM_STR_ARRAY) + ); + if (count($scope['uuids']) === 1) { + array_pop($conditions); + $conditions[] = $qb->expr()->eq( + 't._organisation', + $qb->createNamedParameter($scope['uuids'][0]) + ); + } + + // Condition 2: objects with no organisation — ONLY for admin users. + if ($scope['mode'] === self::SCOPE_IN_OR_NULL) { + $conditions[] = $qb->expr()->isNull('t._organisation'); + } + + $qb->andWhere($qb->expr()->orX(...$conditions)); + + }//end applyOrganizationFilter() + + /** + * Decide WHICH rows the current caller may see, without building any SQL. + * + * This is the single source of truth for the organisation boundary. It was + * extracted from {@see applyOrganizationFilter()} because a SECOND + * implementation had grown in `AggregationRunner::tryNativeAggregation()`, + * where the whole rule had been flattened to a bare + * `_organisation = :activeOrg`. SQL `=` never matches NULL, so every object + * with no organisation was invisible to the aggregation API while the list + * API returned it — measured 2026-08-30 on decidiq/meeting: four meetings + * listed, one of them org-less, `count` answered 3, and + * `filter[lifecycle]=closed` answered 0 for a meeting that plainly exists. + * Every KPI tile in the fleet reads that endpoint, so each one silently + * under-reported. + * + * Returning a DECISION rather than a query fragment is the point: a caller + * that cannot render one of these modes must refuse to run rather than + * approximate it, and the rule itself now lives in exactly one place. + * + * @param bool $adminBypassEnabled Whether the caller honours the admin bypass (disabled in SaaS mode). + * + * @return array{mode: string, uuids: array} `mode` is one of the SCOPE_* constants. + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) + * Moved here with the code they were written for: this method IS the + * branchy decision that used to sit inline in applyOrganizationFilter, + * which has carried these three suppressions since it was written. The + * branches are the tenancy rule itself (system context, admin, bypass, + * SaaS, active-org set) and collapsing them would hide it. + */ + public function resolveOrganizationScope(bool $adminBypassEnabled = false): array { $user = $this->userSession->getUser(); // CLI / no-session system context (occ commands, repair steps, cron // jobs, background calculations, system listeners) is a trusted system // operation and must see all org-owned rows. See isSystemContext(). if ($this->isSystemContext(user: $user) === true) { - return; + return ['mode' => self::SCOPE_ALL, 'uuids' => []]; } - // Check if user is admin - admins can see all objects including those with null organization. + // Admins can see all objects, including those with no organisation. $isAdmin = false; if ($user !== null) { $userGroups = $this->groupManager->getUserGroupIds($user); $isAdmin = in_array('admin', $userGroups, true); } - // Check if admin bypass is enabled (disabled in SaaS mode). if ($adminBypassEnabled === true && $isAdmin === true) { - // In SaaS mode, never bypass organisation boundary. + // In SaaS mode, never bypass the organisation boundary. $saasMode = $this->isSaasModeEnabled(); if ($saasMode === true) { $this->logger->debug( @@ -124,49 +225,33 @@ public function applyOrganizationFilter( } if ($saasMode !== true) { - return; + return ['mode' => self::SCOPE_ALL, 'uuids' => []]; } } - // Get the active organization UUID(s) for the current user. $activeOrgUuids = $this->getActiveOrganizationUuids(); if (empty($activeOrgUuids) === true) { - // No active organization - admins can see null-org objects, others get no results. - if ($isAdmin !== true) { - $qb->andWhere('1 = 0'); - return; + // No active organisation: admins still see org-less rows, others see nothing. + $emptyMode = self::SCOPE_NONE; + if ($isAdmin === true) { + $emptyMode = self::SCOPE_NULL_ONLY; } - $qb->andWhere($qb->expr()->isNull('t._organisation')); - return; - }//end if - - // Build conditions for organization filtering. - $conditions = []; - - // Condition 1: Objects belonging to the user's active organization(s). - $conditions[] = $qb->expr()->in( - 't._organisation', - $qb->createNamedParameter($activeOrgUuids, IQueryBuilder::PARAM_STR_ARRAY) - ); - if (count($activeOrgUuids) === 1) { - array_pop($conditions); - $conditions[] = $qb->expr()->eq( - 't._organisation', - $qb->createNamedParameter($activeOrgUuids[0]) - ); + return ['mode' => $emptyMode, 'uuids' => []]; } - // Condition 2: Objects with null organization - ONLY for admin users. + $scopedMode = self::SCOPE_IN; if ($isAdmin === true) { - $conditions[] = $qb->expr()->isNull('t._organisation'); + $scopedMode = self::SCOPE_IN_OR_NULL; } - // Apply OR of all conditions. - $qb->andWhere($qb->expr()->orX(...$conditions)); + return [ + 'mode' => $scopedMode, + 'uuids' => array_values($activeOrgUuids), + ]; - }//end applyOrganizationFilter() + }//end resolveOrganizationScope() /** * Determine whether the current call is a trusted system (CLI/no-session) diff --git a/lib/Db/Organisation.php b/lib/Db/Organisation.php index 402320dc88..237c2eb77a 100644 --- a/lib/Db/Organisation.php +++ b/lib/Db/Organisation.php @@ -77,6 +77,10 @@ * @method void setDeprovisionedAt(?DateTime $deprovisionedAt) * @method string|null getType() * @method void setType(?string $type) + * @method bool|null getIsLocalTenant() + * @method void setIsLocalTenant(?bool $isLocalTenant) + * @method string|null getRemoteInstanceUrl() + * @method void setRemoteInstanceUrl(?string $remoteInstanceUrl) * @method string|null getSummary() * @method void setSummary(?string $summary) * @method string|null getOin() @@ -301,6 +305,40 @@ class Organisation extends Entity implements JsonSerializable { */ protected ?string $type = 'organisation'; + /** + * Whether this organisation is a tenant OF THIS INSTALLATION. + * + * 🔴 THIS ONE IS AN AUTHORIZATION AND LIFECYCLE INPUT, unlike `type` above, + * which is deliberately not. It answers a question `type` cannot: a row may + * be a perfectly real organisation — a ketenpartner, a supplier, a + * municipality we exchange cases with — and still not be a tenant here. + * + * It exists because the tenant BACKGROUND JOBS select on `status` alone, + * and one of them (TenantPurgeJob) permanently DELETES what it selects. + * Without this flag an archived counterparty is indistinguishable from an + * archived tenant, and is deleted with it. Every tenant enumeration filters + * on this; see TenantJobsScopeTest. + * + * Defaults to TRUE so every existing row keeps its present meaning. + * + * @var boolean|null True for a tenant of this installation. + */ + protected ?bool $isLocalTenant = true; + + /** + * Base URL of the OpenRegister instance this organisation is a tenant of. + * + * Set only on a counterparty — an organisation that is a tenant somewhere + * ELSE and interacts with us across the federation. It is the same value + * `FederatedShare.remoteInstanceUrl` carries, which is what lets a share + * and the organisation it is with be resolved to each other. + * + * Empty for a local tenant, which is the ordinary case. + * + * @var string|null The peer instance base URL. + */ + protected ?string $remoteInstanceUrl = null; + /** * Short summary for overview pages (OpenCatalogi `summary`). * @@ -501,6 +539,8 @@ public function __construct() { // Identity facet (ADR-022 §3): the statutory identifiers a leaf app // used to keep in its own publisher/vendor record. $this->addType(fieldName: 'type', type: 'string'); + $this->addType(fieldName: 'isLocalTenant', type: 'boolean'); + $this->addType(fieldName: 'remoteInstanceUrl', type: 'string'); $this->addType(fieldName: 'summary', type: 'string'); $this->addType(fieldName: 'oin', type: 'string'); $this->addType(fieldName: 'tooi', type: 'string'); @@ -948,6 +988,8 @@ public function jsonSerialize(): array { 'status' => $this->status ?? 'active', 'environment' => $this->environment ?? 'production', 'type' => $this->type ?? 'organisation', + 'isLocalTenant' => ($this->isLocalTenant ?? true), + 'remoteInstanceUrl' => $this->remoteInstanceUrl, 'summary' => $this->summary, 'oin' => $this->oin, 'tooi' => $this->tooi, diff --git a/lib/Db/OrganisationMapper.php b/lib/Db/OrganisationMapper.php index 263c8512d9..8be70766d3 100644 --- a/lib/Db/OrganisationMapper.php +++ b/lib/Db/OrganisationMapper.php @@ -440,6 +440,66 @@ private function validateUuid(Organisation $organisation): void { * @return array Array of matching organisations */ + /** + * Find organisations that are tenants OF THIS INSTALLATION. + * + * A named method rather than one more filter every caller must remember, + * because forgetting it is destructive: the tenant background jobs select on + * `status` alone and TenantPurgeJob PERMANENTLY DELETES what it selects, so + * a federated counterparty that happens to be archived would be deleted as + * though it were a spent tenant. The name is the guarantee, and it is + * greppable. + * + * 🔴 A NULL counts as a tenant. `is_local_tenant` was added by migration and + * every row written before it holds NULL, so a plain `= true` would make + * every pre-existing tenant invisible to all three jobs at once — tenants + * would stop being deprovisioned, purged and metered, and nothing would + * report it. Only a row explicitly marked false is excluded. + * + * @param int $limit Maximum number of results. + * @param int $offset Number of results to skip. + * @param array $filters Column => value equality filters. + * + * @return Organisation[] + * + * @psalm-return list<\OCA\OpenRegister\Db\Organisation> + * + * @spec openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md + */ + public function findLocalTenants(int $limit = 50, int $offset = 0, ?array $filters = []): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('name', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset); + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('is_local_tenant'), + $qb->expr()->eq('is_local_tenant', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)) + ) + ); + + foreach ($filters ?? [] as $filter => $value) { + if ($value === 'IS NOT NULL') { + $qb->andWhere($qb->expr()->isNotNull($filter)); + continue; + } + + if ($value === 'IS NULL') { + $qb->andWhere($qb->expr()->isNull($filter)); + continue; + } + + $qb->andWhere($qb->expr()->eq($filter, $qb->createNamedParameter($value))); + } + + return $this->findEntities(query: $qb); + + }//end findLocalTenants() + /** * Find all organisations with pagination and optional column filters * diff --git a/lib/Migration/Version1Date20260831020000.php b/lib/Migration/Version1Date20260831020000.php new file mode 100644 index 0000000000..f319fb20fb --- /dev/null +++ b/lib/Migration/Version1Date20260831020000.php @@ -0,0 +1,136 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * Give an organisation a way to say it is NOT a tenant of this installation. + * + * `Organisation` already carried a `type` discriminator, and that one is + * deliberately NOT an authorization input (ADR-002 Rule 1: the UUID is the only + * tenant key). So there was no field anything could safely consult to tell a + * tenant from a counterparty — an organisation that is a tenant somewhere ELSE + * and interacts with us across the federation. + * + * 🔴 That gap is destructive, not cosmetic. The three tenant background jobs + * select organisations by `status` alone, and TenantPurgeJob PERMANENTLY + * DELETES what it selects. An archived ketenpartner in the same table is + * indistinguishable from an archived tenant and is deleted with it. + * + * `is_local_tenant` defaults to TRUE so every existing row keeps exactly the + * meaning it has today; only rows explicitly marked otherwise change behaviour. + * + * @category Migration + * @package OCA\OpenRegister\Migration + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/specs/tenant-lifecycle/spec.md#requirement-database-migration-must-add-lifecycle-fields-to-organisation-entity + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the counterparty discriminator and its federation link. + * + * @spec openspec/specs/tenant-lifecycle/spec.md#requirement-database-migration-must-add-lifecycle-fields-to-organisation-entity + */ +class Version1Date20260831020000 extends SimpleMigrationStep { + + /** + * The table this migration changes. + */ + private const TABLE = 'openregister_organisations'; + + /** + * The columns this migration adds. + * + * @return array}> The specifications. + */ + private function columnSpecifications(): array { + return [ + [ + 'name' => 'is_local_tenant', + 'type' => Types::BOOLEAN, + 'options' => [ + 'notnull' => false, + 'default' => true, + 'comment' => 'True when this organisation is a tenant of THIS installation; ' + . 'false for a federated counterparty. Unlike `type`, this IS ' + . 'consulted by tenancy and by the purge job.', + ], + ], + [ + 'name' => 'remote_instance_url', + 'type' => Types::STRING, + 'options' => [ + 'notnull' => false, + 'length' => 512, + 'comment' => 'Base URL of the OpenRegister instance a counterparty is a ' + . 'tenant of; matches FederatedShare.remote_instance_url. ' + . 'Empty for a local tenant.', + ], + ], + ]; + + }//end columnSpecifications() + + /** + * Add the columns. + * + * @param IOutput $output Migration output. + * @param Closure $schemaClosure The schema closure. + * @param array $options Migration options. + * + * @return ISchemaWrapper|null The changed schema, or null when nothing changed. + * + * @spec openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /* @var ISchemaWrapper $schema The schema wrapper. */ + $schema = $schemaClosure(); + + // This class sorts AFTER the one that creates the table, so the guard is + // the ordinary "already ran" check and not a permanent skip — the trap + // Version1Date20250102000000 fell into and documents. + if ($schema->hasTable(self::TABLE) === false) { + $output->warning(message: 'openregister_organisations is absent; skipping the counterparty columns'); + + return null; + } + + $table = $schema->getTable(self::TABLE); + $added = []; + + foreach ($this->columnSpecifications() as $column) { + if ($table->hasColumn($column['name']) === true) { + continue; + } + + $table->addColumn($column['name'], $column['type'], $column['options']); + $added[] = $column['name']; + } + + if ($added === []) { + return null; + } + + $output->info(message: 'openregister_organisations: added ' . implode(', ', $added)); + + return $schema; + + }//end changeSchema() + +}//end class diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index 3a70d64bba..8c1c2f30b4 100644 --- a/lib/Service/Aggregation/AggregationRunner.php +++ b/lib/Service/Aggregation/AggregationRunner.php @@ -39,6 +39,7 @@ use DateTimeInterface; use DateTimeZone; use OCA\OpenRegister\Db\MagicMapper; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; @@ -141,6 +142,7 @@ class AggregationRunner { * @param PermissionHandler $permissionHandler RBAC verdict on the schema's `list` action. * @param IUserSession $userSession Active session, for the RBAC + cache-key user scope. * @param OrganisationService $organisationService Active-organisation lookup for the cache key. + * @param MagicOrganizationHandler $organizationHandler Owns the organisation-boundary decision the native SQL renders. * @param TranslationHandler $translationHandler Resolves translatable group keys to the negotiated language. * @param LanguageService $languageService Request-scoped language negotiation (Accept-Language / _lang). * @param LoggerInterface|null $logger Optional logger for diagnostics. @@ -164,6 +166,7 @@ public function __construct( private readonly PermissionHandler $permissionHandler, private readonly IUserSession $userSession, private readonly OrganisationService $organisationService, + private readonly MagicOrganizationHandler $organizationHandler, private readonly TranslationHandler $translationHandler, private readonly LanguageService $languageService, private readonly ?LoggerInterface $logger = null, @@ -2511,15 +2514,53 @@ private function tryNativeAggregation( $whereParts[] = "(_deleted IS NULL OR _deleted = 'null' OR _deleted = '')"; } - // SECURITY: mirror MagicRbacHandler's multi-tenancy predicate. The - // native fast path bypasses MagicMapper entirely, so without this - // filter any authed caller could compute aggregates over rows in - // other tenants. Active org of `null` ⇒ no rows (fail-closed). - // Column is `_organisation` — magic tables prefix metadata cols - // with `_` (see MagicMapper::METADATA_PREFIX). - $activeOrg = $this->organisationService->getActiveOrganisation(); - $whereParts[] = $quote . '_organisation' . $quote . ' = ?'; - $bindings[] = $activeOrg?->getUuid() ?? '__no_active_org__'; + // SECURITY: the organisation boundary. This fast path bypasses + // MagicMapper entirely, so it must reproduce the SAME rule the list + // path applies — no wider, and no narrower. + // + // It used to carry its own flattened copy: a bare + // `_organisation = :activeOrg`. That was narrower in a way nobody saw, + // because SQL `=` never matches NULL: every object with no + // organisation was invisible here while the list API returned it. + // Measured 2026-08-30 on decidiq/meeting — four meetings listed, one + // org-less, `count` answered 3, and `filter[lifecycle]=closed` + // answered 0 for a meeting that plainly exists. Every KPI tile in the + // fleet reads this endpoint, so every one of them under-reported. + // + // The rule now has one home: MagicOrganizationHandler decides, and + // both paths render that decision. A mode this SQL cannot express is + // a refusal (`return null` ⇒ the PHP fallback, which goes through + // MagicMapper), never an approximation. + // Column is `_organisation` — magic tables prefix metadata cols with + // `_` (see MagicMapper::METADATA_PREFIX). + $orgScope = $this->organizationHandler->resolveOrganizationScope(); + $orgColumn = $quote . '_organisation' . $quote; + switch ($orgScope['mode']) { + case MagicOrganizationHandler::SCOPE_ALL: + break; + case MagicOrganizationHandler::SCOPE_NONE: + $whereParts[] = '1 = 0'; + break; + case MagicOrganizationHandler::SCOPE_NULL_ONLY: + $whereParts[] = $orgColumn . ' IS NULL'; + break; + case MagicOrganizationHandler::SCOPE_IN: + case MagicOrganizationHandler::SCOPE_IN_OR_NULL: + $placeholders = implode(', ', array_fill(0, count($orgScope['uuids']), '?')); + $inClause = $orgColumn . ' IN (' . $placeholders . ')'; + if ($orgScope['mode'] === MagicOrganizationHandler::SCOPE_IN_OR_NULL) { + $inClause = '(' . $inClause . ' OR ' . $orgColumn . ' IS NULL)'; + } + + $whereParts[] = $inClause; + foreach ($orgScope['uuids'] as $orgUuid) { + $bindings[] = $orgUuid; + } + break; + default: + // An unknown mode is not something to guess at. + return null; + }//end switch foreach ($filter as $f => $v) { $col = $this->sanitizeColumnName(name: (string)$f); diff --git a/lib/Service/Dmn/DecisionEvaluationException.php b/lib/Service/Dmn/DecisionEvaluationException.php new file mode 100644 index 0000000000..9636ab379c --- /dev/null +++ b/lib/Service/Dmn/DecisionEvaluationException.php @@ -0,0 +1,73 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +use RuntimeException; + +/** + * Typed evaluation failure with a stable machine-readable error code. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ +class DecisionEvaluationException extends RuntimeException { + /** + * Constructor. + * + * @param string $errorCode Stable machine-readable error code. + * @param array $details Optional structured details (e.g. offending key/expression). + */ + public function __construct( + private readonly string $errorCode, + private readonly array $details = [], + ) { + parent::__construct(message: $errorCode); + }//end __construct() + + /** + * The stable error code. + * + * @return string + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function getErrorCode(): string { + return $this->errorCode; + }//end getErrorCode() + + /** + * Structured details for logging/debugging (never shown raw to end users). + * + * @return array + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function getDetails(): array { + return $this->details; + }//end getDetails() +}//end class diff --git a/lib/Service/Dmn/DecisionTableEvaluator.php b/lib/Service/Dmn/DecisionTableEvaluator.php new file mode 100644 index 0000000000..7ca2f0a35c --- /dev/null +++ b/lib/Service/Dmn/DecisionTableEvaluator.php @@ -0,0 +1,434 @@ + outputs. Never silently defaults: every + * ambiguous or invalid situation surfaces as a typed + * {@see DecisionEvaluationException}. + * + * @category Service + * @package OCA\OpenRegister\Service\Dmn + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +/** + * Evaluates a decisionTable definition against a runtime inputs map. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ +class DecisionTableEvaluator { + + /** + * Hit policies fully implemented by this engine. + * + * @var string[] + */ + /** + * The hit policies this evaluator implements. + * + * PRIORITY is here because openbuild's evaluator had it and dossiq's did + * not. ADR-065 names that explicitly: consolidating without it would be a + * capability REGRESSION dressed up as a consolidation, since openbuild's + * tables can already use it. This list is the union of the two, not the + * intersection. + * + * @var array + */ + private const IMPLEMENTED_HIT_POLICIES = ['UNIQUE', 'FIRST', 'COLLECT', 'PRIORITY', 'ANY']; + + /** + * Common spellings of the declared column types, mapped onto this + * evaluator's own vocabulary. + * + * The fleet's tables were authored against two different vocabularies: + * openbuild's shipped credit-approval table declares `integer` for + * `applicantAge`, which is not one of {@see UnaryTestEvaluator::VALID_TYPES}, + * so it falls back to `string`. + * + * Measured, that fallback costs nothing for numbers or dates — the unary-test + * grammar parses its operand and compares numerically, so `>=18` against 25 + * matches under either type, and ISO dates compare correctly as strings. The + * numeric and date aliases below are therefore alignment, not a bug fix. + * + * The boolean alias IS a bug fix: a real PHP `true` coerced to a string + * becomes `"1"`, which does not match a cell reading `true`, so a column + * declaring `bool` rather than `boolean` silently stops matching. That is the + * one case where the fallback changes the answer instead of the spelling. + * + * Aliasing is additive: a type this map does not know still falls back to + * `string` exactly as before. + * + * @var array + */ + private const TYPE_ALIASES = [ + 'int' => 'number', + 'integer' => 'number', + 'long' => 'number', + 'float' => 'number', + 'double' => 'number', + 'decimal' => 'number', + 'bool' => 'boolean', + 'datetime' => 'date', + 'timestamp' => 'date', + ]; + + /** + * Constructor. + * + * The evaluator is a pure, stateless collaborator; the default keeps the + * engine directly constructible (`new DecisionEngine()`) while the + * Nextcloud container autowires the concrete class when resolved via DI. + * + * @param UnaryTestEvaluator $evaluator The rule-cell expression evaluator. + * + * @return void + */ + public function __construct( + private readonly UnaryTestEvaluator $evaluator = new UnaryTestEvaluator(), + ) { + }//end __construct() + + /** + * Evaluate a decision table. + * + * @param array $decisionTable The decision table definition + * (`inputs`, `outputs`, `rules`, `hitPolicy`). + * @param array $inputs Caller-supplied input values, keyed by input name. + * + * @return array{outputs: array, matchedRuleIds: array, hitPolicy: string} + * + * @throws DecisionEvaluationException `unknown_input`, `missing_input`, `type_mismatch`, + * `invalid_expression`, `no_rule_matched`, + * `hit_policy_violation`, `hit_policy_not_implemented`. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function evaluate(array $decisionTable, array $inputs): array { + $declaredInputs = self::normaliseFields(fields: ($decisionTable['inputs'] ?? [])); + $declaredOutputs = self::normaliseFields(fields: ($decisionTable['outputs'] ?? [])); + $hitPolicy = strtoupper((string)($decisionTable['hitPolicy'] ?? 'UNIQUE')); + + $rules = []; + if (is_array($decisionTable['rules'] ?? null) === true) { + $rules = $decisionTable['rules']; + } + + if (in_array($hitPolicy, self::IMPLEMENTED_HIT_POLICIES, true) === false) { + throw new DecisionEvaluationException(errorCode: 'hit_policy_not_implemented', details: ['hitPolicy' => $hitPolicy]); + } + + $coercedInputs = $this->resolveInputs(declaredInputs: $declaredInputs, inputs: $inputs); + + $matchedRules = []; + foreach ($rules as $index => $rule) { + if (is_array($rule) === false) { + continue; + } + + if ($this->ruleMatches(rule: $rule, declaredInputs: $declaredInputs, coercedInputs: $coercedInputs, ruleIndex: $index) === true) { + $matchedRules[] = $rule; + } + } + + return $this->applyHitPolicy( + hitPolicy: $hitPolicy, + matchedRules: $matchedRules, + declaredOutputs: $declaredOutputs, + ); + }//end evaluate() + + /** + * Validate the caller's inputs against the declared inputs and coerce + * each to its declared type. + * + * @param array $declaredInputs Declared inputs. + * @param array $inputs Caller-supplied values. + * + * @return array Coerced values keyed by input name. + * + * @throws DecisionEvaluationException `unknown_input`, `missing_input`, `type_mismatch`. + */ + private function resolveInputs(array $declaredInputs, array $inputs): array { + $declaredNames = array_map(static fn (array $input): string => $input['name'], $declaredInputs); + + foreach (array_keys($inputs) as $key) { + if (in_array($key, $declaredNames, true) === false) { + throw new DecisionEvaluationException(errorCode: 'unknown_input', details: ['key' => $key]); + } + } + + $coerced = []; + foreach ($declaredInputs as $declared) { + $name = $declared['name']; + if (array_key_exists($name, $inputs) === false) { + throw new DecisionEvaluationException(errorCode: 'missing_input', details: ['name' => $name]); + } + + $coerced[$name] = $this->evaluator->coerce(value: $inputs[$name], type: $declared['type']); + } + + return $coerced; + }//end resolveInputs() + + /** + * Check whether every input entry on a rule matches the coerced inputs. + * + * @param array $rule The rule row. + * @param array $declaredInputs Declared inputs, positionally aligned. + * @param array $coercedInputs Coerced runtime values, keyed by name. + * @param int|string $ruleIndex Rule position (for error context). + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression`/`type_mismatch` (re-thrown with rule context). + */ + private function ruleMatches(array $rule, array $declaredInputs, array $coercedInputs, int|string $ruleIndex): bool { + $entries = []; + if (is_array($rule['inputEntries'] ?? null) === true) { + $entries = $rule['inputEntries']; + } + + foreach ($declaredInputs as $position => $declared) { + $expression = (string)($entries[$position] ?? '-'); + $value = $coercedInputs[$declared['name']]; + + try { + if ($this->evaluator->matches(expression: $expression, value: $value, type: $declared['type']) === false) { + return false; + } + } catch (DecisionEvaluationException $e) { + throw new DecisionEvaluationException( + errorCode: $e->getErrorCode(), + details: array_merge($e->getDetails(), ['ruleId' => ($rule['id'] ?? $ruleIndex), 'input' => $declared['name']]), + ); + } + } + + return true; + }//end ruleMatches() + + /** + * Refuse an ANY table whose matching rules disagree. + * + * @param array> $matchedRules The matching rules. + * @param array $declaredOutputs The declared outputs. + * @param array $matchedIds The matching rule ids, for the error. + * + * @return void + * + * @throws DecisionEvaluationException `hit_policy_violation` when they differ. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function assertAllOutputsAgree(array $matchedRules, array $declaredOutputs, array $matchedIds): void { + $first = null; + foreach ($matchedRules as $rule) { + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = ($rule['outputEntries'][$position] ?? null); + } + + if ($first === null) { + $first = $outputs; + continue; + } + + if ($outputs !== $first) { + throw new DecisionEvaluationException( + errorCode: 'hit_policy_violation', + details: ['hitPolicy' => 'ANY', 'matchedRuleIds' => $matchedIds], + ); + } + } + + }//end assertAllOutputsAgree() + + /** + * Every matching rule's value, per declared output. + * + * COLLECT is the one policy whose result is a LIST rather than a value, so + * it is built apart from the single-winner path instead of inside it. + * + * @param array> $matchedRules The matching rules. + * @param array $declaredOutputs The declared outputs. + * + * @return array> The collected outputs. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function collectOutputs(array $matchedRules, array $declaredOutputs): array { + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = array_map( + static fn (array $rule): mixed => ($rule['outputEntries'][$position] ?? null), + $matchedRules, + ); + } + + return $outputs; + + }//end collectOutputs() + + /** + * Which matching rule wins, for the single-winner policies. + * + * PRIORITY takes the highest `priority`; everything else takes the first in + * declaration order. Ties are not an error under PRIORITY — DMN says the + * output with the highest priority is taken, and two rules may legitimately + * share one — so declaration order breaks them, which makes the outcome + * deterministic rather than dependent on array iteration. + * + * @param string $hitPolicy The hit policy. + * @param array> $matchedRules The matching rules, in declaration order. + * + * @return array The winning rule. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function selectWinner(string $hitPolicy, array $matchedRules): array { + if ($hitPolicy === 'PRIORITY') { + return $this->highestPriority(matchedRules: $matchedRules); + } + + return $matchedRules[0]; + + }//end selectWinner() + + /** + * The matched rule with the highest `priority`, ties broken by order. + * + * A rule that declares no priority is treated as 0, so a table that mixes + * prioritised and unprioritised rules behaves predictably instead of + * depending on whether the key happens to exist. + * + * @param array> $matchedRules The matching rules, in declaration order. + * + * @return array The winning rule. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + private function highestPriority(array $matchedRules): array { + $winner = $matchedRules[0]; + $best = (int) ($winner['priority'] ?? 0); + + foreach ($matchedRules as $rule) { + $priority = (int) ($rule['priority'] ?? 0); + // STRICTLY greater, so an equal priority leaves the earlier rule in + // place and declaration order is the tie-break. + if ($priority > $best) { + $winner = $rule; + $best = $priority; + } + } + + return $winner; + + }//end highestPriority() + + /** + * Apply the hit policy to the set of matched rules and build the outputs. + * + * @param string $hitPolicy UNIQUE|FIRST|COLLECT. + * @param array> $matchedRules Rules that matched, in declaration order. + * @param array $declaredOutputs Declared outputs, positionally aligned. + * + * @return array{outputs: array, matchedRuleIds: array, hitPolicy: string} + * + * @throws DecisionEvaluationException `no_rule_matched`, `hit_policy_violation`. + */ + private function applyHitPolicy(string $hitPolicy, array $matchedRules, array $declaredOutputs): array { + $matchedIds = []; + foreach ($matchedRules as $position => $rule) { + $matchedIds[] = (string)($rule['id'] ?? $position); + } + + if ($hitPolicy === 'COLLECT') { + return [ + 'outputs' => $this->collectOutputs(matchedRules: $matchedRules, declaredOutputs: $declaredOutputs), + 'matchedRuleIds' => $matchedIds, + 'hitPolicy' => $hitPolicy, + ]; + } + + if (count($matchedRules) === 0) { + throw new DecisionEvaluationException(errorCode: 'no_rule_matched'); + } + + if ($hitPolicy === 'UNIQUE' && count($matchedRules) > 1) { + throw new DecisionEvaluationException(errorCode: 'hit_policy_violation', details: ['matchedRuleIds' => $matchedIds]); + } + + // ANY: every matching rule must agree. DMN says a table declaring ANY + // asserts that overlapping rules produce the SAME output, so a + // disagreement is a fault in the table, not a choice to make silently. + // openbuild's evaluator treated `any` as `collect` and returned a list; + // that is a different answer of a different shape, and consolidating it + // unexamined would have been the quiet regression this whole exercise + // exists to avoid. + if ($hitPolicy === 'ANY') { + $this->assertAllOutputsAgree(matchedRules: $matchedRules, declaredOutputs: $declaredOutputs, matchedIds: $matchedIds); + } + + $winner = $this->selectWinner(hitPolicy: $hitPolicy, matchedRules: $matchedRules); + + $outputs = []; + foreach ($declaredOutputs as $position => $declared) { + $outputs[$declared['name']] = ($winner['outputEntries'][$position] ?? null); + } + + $winnerId = (string)($winner['id'] ?? 0); + + return ['outputs' => $outputs, 'matchedRuleIds' => [$winnerId], 'hitPolicy' => $hitPolicy]; + }//end applyHitPolicy() + + /** + * Normalise a decision table's `inputs`/`outputs` array into a clean + * positional list of `{name, type}`. + * + * @param array $fields Raw `inputs`/`outputs` array. + * + * @return array + */ + private static function normaliseFields(array $fields): array { + $result = []; + foreach ($fields as $field) { + if (is_array($field) === false) { + continue; + } + + $name = (string)($field['name'] ?? ''); + if ($name === '') { + continue; + } + + $type = strtolower((string)($field['type'] ?? 'string')); + $type = (self::TYPE_ALIASES[$type] ?? $type); + if (in_array($type, UnaryTestEvaluator::VALID_TYPES, true) === false) { + $type = 'string'; + } + + $result[] = ['name' => $name, 'type' => $type]; + } + + return $result; + }//end normaliseFields() +}//end class diff --git a/lib/Service/Dmn/UnaryTestEvaluator.php b/lib/Service/Dmn/UnaryTestEvaluator.php new file mode 100644 index 0000000000..321ebc5c97 --- /dev/null +++ b/lib/Service/Dmn/UnaryTestEvaluator.php @@ -0,0 +1,408 @@ + X' '>= X' '= X' '!= X' comparison, X coerced to type + * '[A..B]' '(A..B)' '[A..B)' '(A..B]' inclusive/exclusive range + * 'in (a,b,c)' set membership (members may be quoted) + * 'literal' bare-literal equality + * + * @category Service + * @package OCA\OpenRegister\Service\Dmn + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\Dmn; + +use DateTimeImmutable; +use Throwable; + +/** + * DMN unary tests: does one rule cell match one input value. + * + * MOVED, not rewritten. This is dossiq's ExpressionEvaluator, ported verbatim + * apart from its namespace and name, because ADR-065 Decision 6 says decision + * tables consolidate HERE and the fleet had already built the grammar twice — + * openbuild on 2026-06-05 and dossiq on 2026-07-15, six weeks apart, neither + * knowing the other existed. Retyping it would have been a third. + * + * dossiq's is the stronger of the two dialects: it carries typed coercion + * (string / number / boolean / date), inclusive and exclusive ranges, set + * membership and the quoted-literal escape that lets a rule match a literal + * "-" rather than reading it as the wildcard. openbuild's contributes the + * `priority` hit policy, which the table evaluator beside this class takes. + * + * Renamed from ExpressionEvaluator because it does NOT evaluate expressions in + * the general sense: it decides whether a value satisfies one unary test, which + * is a closed, bounded grammar with no code execution in it. The old name + * invited someone to reach for it as a general evaluator. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) — a closed grammar parser is + * branchy by nature; every branch is a fixed, tested form. Carried over with the + * class: adding a second docblock above the original detached this tag from the + * class and the rule fired on a file nobody had changed a line of. + */ +class UnaryTestEvaluator { + + /** + * Declared input/output types this evaluator understands. + * + * @var string[] + */ + public const VALID_TYPES = ['string', 'number', 'boolean', 'date']; + + /** + * Check whether a rule cell expression matches an already-coerced value. + * + * @param string $expression The raw cell text (e.g. `'[0..25000]'`, `'-'`, `'in (a,b)'`). + * @param mixed $value The runtime value, already coerced via {@see coerce()} for `$type`. + * @param string $type One of {@see VALID_TYPES}. + * + * @return bool True when the expression matches the value. + * + * @throws DecisionEvaluationException `invalid_expression` on malformed grammar, + * `type_mismatch` when a literal in the expression + * cannot be coerced to `$type`. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) — one dispatch per grammar form; splitting hides the grammar + * @SuppressWarnings(PHPMD.NPathComplexity) — same: the branches are a flat form-dispatch, not nested logic + */ + public function matches(string $expression, mixed $value, string $type): bool { + $trimmed = trim($expression); + + // Explicit quoted literal — bypasses the wildcard shortcut so a rule + // author can match the literal string "-" by writing `"-"`. + if (strlen($trimmed) >= 2 && $trimmed[0] === '"' && str_ends_with($trimmed, '"') === true) { + $literal = $this->unquote(raw: $trimmed); + return $this->equals(left: $value, right: $this->coerce(value: $literal, type: $type), type: $type); + } + + if ($trimmed === '' || $trimmed === '-') { + return true; + } + + if (preg_match('/^in\s*\((.*)\)$/is', $trimmed, $setMatch) === 1) { + $members = $this->parseSetMembers(inner: $setMatch[1]); + foreach ($members as $member) { + if ($this->equals(left: $value, right: $this->coerce(value: $member, type: $type), type: $type) === true) { + return true; + } + } + + return false; + } + + if (preg_match('/^([\[(])\s*(.*?)\s*\.\.\s*(.*?)\s*([\])])$/s', $trimmed, $rangeMatch) === 1) { + return $this->matchesRange(match: $rangeMatch, value: $value, type: $type); + } + + // Two-character operators BEFORE single-character ones (`<=` before `<`). + foreach (['<=', '>=', '!='] as $operator) { + if (str_starts_with($trimmed, $operator) === true) { + return $this->matchesComparison(operator: $operator, remainder: substr($trimmed, 2), value: $value, type: $type); + } + } + + foreach (['<', '>', '='] as $operator) { + if (str_starts_with($trimmed, $operator) === true) { + return $this->matchesComparison(operator: $operator, remainder: substr($trimmed, 1), value: $value, type: $type); + } + } + + // Bare literal — plain equality. + return $this->equals(left: $value, right: $this->coerce(value: $trimmed, type: $type), type: $type); + }//end matches() + + /** + * Coerce a raw scalar (runtime input or rule-cell literal) to `$type`. + * + * @param mixed $value The raw value. + * @param string $type One of {@see VALID_TYPES}. + * + * @return string|float|bool|int The coerced value (int for `date`, a Unix timestamp). + * + * @throws DecisionEvaluationException `type_mismatch` when coercion fails. + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function coerce(mixed $value, string $type): string|float|bool|int { + return match ($type) { + 'string' => $this->coerceString(value: $value), + 'number' => $this->coerceNumber(value: $value), + 'boolean' => $this->coerceBoolean(value: $value), + 'date' => $this->coerceDate(value: $value), + default => throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['reason' => 'unsupported_type', 'type' => $type]), + }; + }//end coerce() + + /** + * Coerce to string. + * + * @param mixed $value Raw value. + * + * @return string + * + * @throws DecisionEvaluationException `type_mismatch` for non-scalar input. + */ + private function coerceString(mixed $value): string { + if (is_scalar($value) === false) { + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'string']); + } + + return (string)$value; + }//end coerceString() + + /** + * Coerce to a float. + * + * @param mixed $value Raw value. + * + * @return float + * + * @throws DecisionEvaluationException `type_mismatch` for non-numeric input. + */ + private function coerceNumber(mixed $value): float { + if (is_int($value) === true || is_float($value) === true) { + return (float)$value; + } + + if (is_string($value) === true && is_numeric(trim($value)) === true) { + return (float)trim($value); + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'number', 'value' => $value]); + }//end coerceNumber() + + /** + * Coerce to a bool. + * + * @param mixed $value Raw value. + * + * @return bool + * + * @throws DecisionEvaluationException `type_mismatch` for unrecognised input. + */ + private function coerceBoolean(mixed $value): bool { + if (is_bool($value) === true) { + return $value; + } + + if (is_int($value) === true && ($value === 0 || $value === 1)) { + return ($value === 1); + } + + if (is_string($value) === true) { + $lower = strtolower(trim($value)); + if ($lower === 'true') { + return true; + } + + if ($lower === 'false') { + return false; + } + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'boolean', 'value' => $value]); + }//end coerceBoolean() + + /** + * Coerce to a Unix timestamp (int). + * + * @param mixed $value Raw value. + * + * @return int + * + * @throws DecisionEvaluationException `type_mismatch` for unparsable input. + */ + private function coerceDate(mixed $value): int { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + if (is_string($value) === true && trim($value) !== '') { + try { + return (new DateTimeImmutable(trim($value)))->getTimestamp(); + } catch (Throwable $e) { + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'date', 'value' => $value]); + } + } + + throw new DecisionEvaluationException(errorCode: 'type_mismatch', details: ['expected' => 'date', 'value' => $value]); + }//end coerceDate() + + /** + * Evaluate a parsed range match against a coerced value. + * + * @param array $match Regex capture groups: [0]=full, [1]=open bracket, [2]=low, [3]=high, [4]=close bracket. + * @param mixed $value The already-coerced runtime value. + * @param string $type Declared type. + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression` on a missing bound, `type_mismatch` on an unparsable bound. + */ + private function matchesRange(array $match, mixed $value, string $type): bool { + [, $open, $lowRaw, $highRaw, $close] = $match; + if ($lowRaw === '' || $highRaw === '') { + throw new DecisionEvaluationException(errorCode: 'invalid_expression', details: ['reason' => 'missing_range_bound']); + } + + $low = $this->coerce(value: $lowRaw, type: $type); + $high = $this->coerce(value: $highRaw, type: $type); + + $lowOk = ($value > $low); + if ($open === '[') { + $lowOk = ($value >= $low); + } + + $highOk = ($value < $high); + if ($close === ']') { + $highOk = ($value <= $high); + } + + return ($lowOk === true && $highOk === true); + }//end matchesRange() + + /** + * Evaluate a comparison operator against a coerced value. + * + * @param string $operator One of `< > <= >= = !=`. + * @param string $remainder The raw operand text (before the leading whitespace is trimmed). + * @param mixed $value The already-coerced runtime value. + * @param string $type Declared type. + * + * @return bool + * + * @throws DecisionEvaluationException `invalid_expression` when the operand is empty, `type_mismatch` when it cannot be coerced. + */ + private function matchesComparison(string $operator, string $remainder, mixed $value, string $type): bool { + $operand = trim($remainder); + if ($operand === '') { + throw new DecisionEvaluationException(errorCode: 'invalid_expression', details: ['reason' => 'missing_operand', 'operator' => $operator]); + } + + if (strlen($operand) >= 2 && $operand[0] === '"' && str_ends_with($operand, '"') === true) { + $operand = $this->unquote(raw: $operand); + } + + $coerced = $this->coerce(value: $operand, type: $type); + + return match ($operator) { + '<' => ($value < $coerced), + '<=' => ($value <= $coerced), + '>' => ($value > $coerced), + '>=' => ($value >= $coerced), + '=' => $this->equals(left: $value, right: $coerced, type: $type), + '!=' => ($this->equals(left: $value, right: $coerced, type: $type) === false), + default => throw new DecisionEvaluationException( + errorCode: 'invalid_expression', + details: ['reason' => 'unknown_operator', 'operator' => $operator], + ), + }; + }//end matchesComparison() + + /** + * Type-aware equality. + * + * @param mixed $left Left operand (already coerced). + * @param mixed $right Right operand (already coerced). + * @param string $type Declared type. + * + * @return bool + */ + private function equals(mixed $left, mixed $right, string $type): bool { + if ($type === 'number' || $type === 'date') { + return (abs(((float)$left) - ((float)$right)) < 1.0e-9); + } + + return ($left === $right); + }//end equals() + + /** + * Split the inner text of `in (...)` into raw member strings, respecting + * double-quoted members that may themselves contain commas. + * + * @param string $inner The text between the parentheses. + * + * @return array Raw (still-quoted) member strings. + */ + private function parseSetMembers(string $inner): array { + $members = []; + $buffer = ''; + $inQuotes = false; + $length = strlen($inner); + + for ($i = 0; $i < $length; $i++) { + $char = $inner[$i]; + if ($char === '"') { + $inQuotes = !$inQuotes; + $buffer .= $char; + continue; + } + + if ($char === ',' && $inQuotes === false) { + $members[] = trim($buffer); + $buffer = ''; + continue; + } + + $buffer .= $char; + } + + if (trim($buffer) !== '') { + $members[] = trim($buffer); + } + + return array_map( + function (string $member): string { + if (strlen($member) >= 2 && $member[0] === '"' && str_ends_with($member, '"') === true) { + return $this->unquote(raw: $member); + } + + return $member; + }, + $members, + ); + }//end parseSetMembers() + + /** + * Strip one layer of surrounding double quotes and unescape `\"`. + * + * @param string $raw The quoted raw text, e.g. `'"a b"'`. + * + * @return string + */ + private function unquote(string $raw): string { + $inner = substr($raw, 1, -1); + return str_replace('\\"', '"', $inner); + }//end unquote() +}//end class diff --git a/openapi.json b/openapi.json index f1a9f041fc..0a5968ed65 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "openregister", - "version": "1.1.15-unstable.20260830153944", + "version": "2.0.5-unstable.20260831053109", "description": "Open Register", "license": { "name": "EUPL-1.2" diff --git a/openspec/changes/organisation-as-federated-counterparty/proposal.md b/openspec/changes/organisation-as-federated-counterparty/proposal.md new file mode 100644 index 0000000000..c92e68e8d3 --- /dev/null +++ b/openspec/changes/organisation-as-federated-counterparty/proposal.md @@ -0,0 +1,90 @@ +--- +kind: code +--- + +# Proposal: organisation-as-federated-counterparty + +## Summary + +Let an `Organisation` say that it is **not** a tenant of this installation — that +it is a counterparty, a tenant somewhere else, reachable across the federation. +Then make every tenant enumeration consult that, so the tenant background jobs +stop being able to see one. + +## Motivation + +Consuming apps want to put their partner organisations here. dossiq carries a +`partnerOrganization` schema — name, slug, oin, contactEmail, groupId, isActive +— every field of which `Organisation` already has, plus a `type` discriminator +whose values include `collaboration` and `vendor`. A ketenpartner obviously IS +an organisation. + +The reason it cannot simply move is that this table is also the tenant table. +`Organisation`'s own docblock says every row "is still a full organisation and +still a valid tenant", and ADR-002 Rule 1 makes the organisation UUID the only +tenant key. `type` cannot carry the distinction: it is deliberately NOT an +authorization input. + +## 🔴 The gap is destructive, not cosmetic + +Three background jobs enumerate organisations, and each selects on `status` +alone: + +| Job | Selects | Does | +|---|---|---| +| `TenantUsageSyncJob` | `status = active` | meters usage | +| `TenantDeprovisionJob` | `status = deprovisioning` | tears the tenant down | +| `TenantPurgeJob` | `status = archived` | **permanently deletes the row** | + +So the moment ketenpartners live in this table, an archived partner is +indistinguishable from an archived tenant and is deleted with it. Nothing +throws; the job reports a successful purge. + +That is why this change leads with tests. `TenantJobsScopeTest` was written +against the OLD behaviour first and two of its assertions failed when the +distinction landed, which is the only evidence that it pins anything. + +## Scope + +### In Scope + +1. **`isLocalTenant`** on `Organisation`, defaulting to TRUE so every existing + row keeps exactly the meaning it has today. Unlike `type`, this one IS + consulted by tenancy. +2. **`remoteInstanceUrl`** — the peer OpenRegister base URL a counterparty is a + tenant of. It is the same value `FederatedShare.remoteInstanceUrl` carries, + which is what lets a share and the organisation it is with resolve to each + other. +3. **`OrganisationMapper::findLocalTenants()`** — a named method whose contract + is the guarantee, used by all three jobs. + +### Out of Scope + +- **Moving dossiq's `partnerOrganization`.** That is dossiq's change, and it can + only start once this exists. +- **Narrowing `findAll()`.** The admin organisation list must keep showing + counterparties — they are the ketenpartners the federation exists to work + with. + +## The NULL that would have broken every tenant + +`is_local_tenant` arrives by migration, so every row written before it holds +NULL. A plain `is_local_tenant = true` filter would therefore make **every +pre-existing tenant invisible to all three jobs at once** — tenants would +silently stop being deprovisioned, purged and metered, and each job would report +success over an empty list. + +`findLocalTenants()` treats NULL as a tenant. Only a row explicitly marked false +is excluded, and `OrganisationTenantScopeIntegrationTest` asserts exactly that +against a real database, because a mocked mapper cannot show it. + +## Risks + +- 🔴 **A named method can be forgotten.** The next job someone writes could call + `findAll()`. Mitigated by the name being the contract and greppable, and by + the job tests asserting the tenant-scoped path is the one used. A stronger + guard — making `findAll()` itself default to local tenants — was rejected + because it would silently hide counterparties from the admin surface. +- ⚠️ **`isLocalTenant` is an authorization-adjacent input and `type` is not.** + Two discriminators on one entity invites confusion; each one's docblock says + which question it answers and which it does not. diff --git a/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md b/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md new file mode 100644 index 0000000000..d4736dcc74 --- /dev/null +++ b/openspec/changes/organisation-as-federated-counterparty/specs/organisation-tenancy-scope/spec.md @@ -0,0 +1,67 @@ +# organisation-tenancy-scope Specification + +## Purpose + +Distinguish an organisation that is a tenant of THIS installation from one that +is a counterparty — a tenant elsewhere, reachable across the federation — and +make every tenant enumeration honour the distinction. + +## ADDED Requirements + +### Requirement: REQ-OTS-001 An organisation states whether it is a local tenant + +`Organisation` SHALL carry `isLocalTenant`, defaulting to true, and +`remoteInstanceUrl` naming the peer instance a counterparty belongs to. + +`isLocalTenant` IS an authorization and lifecycle input, unlike `type`, which +ADR-002 keeps out of that role deliberately. + +#### Scenario: Existing rows keep their meaning + +- **GIVEN** an organisation stored before the column existed +- **WHEN** it is read +- **THEN** it is treated as a tenant of this installation + +#### Scenario: A counterparty carries its peer instance + +- **GIVEN** an organisation marked `isLocalTenant: false` with a + `remoteInstanceUrl` +- **WHEN** it is read back +- **THEN** both values round-trip + +### Requirement: REQ-OTS-002 Tenant enumeration excludes counterparties + +`OrganisationMapper` SHALL expose `findLocalTenants()`, which returns only +organisations that are tenants of this installation, and the tenant background +jobs SHALL read through it. + +A NULL `isLocalTenant` SHALL count as a tenant. The column arrives by migration, +so a plain equality filter would make every pre-existing tenant invisible to +every tenant job at once — and each would report success over an empty list. + +#### Scenario: A counterparty is not a tenant + +- **GIVEN** a tenant, a pre-migration row, and a counterparty, all active +- **WHEN** `findLocalTenants()` is asked for active organisations +- **THEN** the tenant and the pre-migration row are returned and the counterparty is not + +#### Scenario: The purge job cannot see a counterparty + +- **GIVEN** the purge job +- **WHEN** it selects archived organisations +- **THEN** it reads through the tenant-scoped path, so a counterparty is never + among the rows it permanently deletes + +### Requirement: REQ-OTS-003 The general listing still shows counterparties + +`findAll()` SHALL keep returning every organisation regardless of tenancy. + +The narrowing is confined to the tenant path on purpose: an organisation list +that hid counterparties would hide the ketenpartners the federation exists to +work with. + +#### Scenario: A counterparty appears in the general listing + +- **GIVEN** a counterparty organisation +- **WHEN** `findAll()` is called +- **THEN** it is returned diff --git a/openspec/changes/shared-decision-table-evaluator/proposal.md b/openspec/changes/shared-decision-table-evaluator/proposal.md new file mode 100644 index 0000000000..ff65ce5597 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/proposal.md @@ -0,0 +1,85 @@ +--- +kind: code +--- + +# Proposal: shared-decision-table-evaluator + +## Summary + +Give the fleet one place to evaluate a DMN decision table. OpenRegister gets the +evaluator; the two apps that each built their own become consumers. + +## Motivation + +ADR-065 Decision 6 states this outright, and states why it is not a matter of +taste: + +> the fleet has already built it twice, independently, and neither +> implementation knows the other exists + +| App | File | Lines | Hit policies | Built | +|---|---|---|---|---| +| openbuild | `DecisionTableEvaluator` | 422 | any, collect, first, **priority**, unique | 2026-06-05 | +| dossiq | `Service/Dmn/*` | ~1,070 | collect, first, unique | 2026-07-15 | + +Six weeks apart, and **the newer one is the less capable**. dossiq's archived +change references OpenRegister only as object storage; it never mentions +openbuild's evaluator, which had been shipping for six weeks. + +The ADR calls that "the ADR's own thesis happening in real time", and it is the +single strongest argument for OpenRegister owning the abstraction: without a +shared home, the third implementation is a matter of time. + +Measured again today, before writing anything: OpenRegister still has no +decision-table service, so nothing has changed since the ADR was written. + +## Scope + +### In Scope + +1. **`UnaryTestEvaluator`** — dossiq's grammar, MOVED rather than rewritten. +2. **`DecisionTableEvaluator`** — the table engine, taking the UNION of the two + dialects' hit policies. +3. **`DecisionEvaluationException`** — the typed error codes, moved with it. + +### Out of Scope + +- **Retiring either app's copy.** Each consumes this in its own change, with + parity tests against its own shipped tables. Deleting a working evaluator on + the strength of a new one that has not yet run its data would be the same + mistake in the other direction. +- **A conformant FEEL implementation.** ADR-065 rules it out: unary tests cover + the overwhelming majority of real tables and full FEEL is not worth the cost. +- **DMN XML interchange.** No package supplies it; the ADR records that as ours + to write later, and it does not block the engine. + +## What "reconcile the dialects" actually meant + +The ADR warned this "must reconcile two dialects rather than simply relocate +one". Three places where that mattered: + +**The grammar is dossiq's, moved verbatim.** It is the stronger of the two — +typed coercion (string / number / boolean / date), inclusive and exclusive +ranges, set membership, and a quoted-literal escape so a rule can match a +literal `"-"` rather than having it read as the wildcard. Retyping it would have +been a third implementation. + +**`PRIORITY` is openbuild's, and its absence would have been a regression.** +dossiq's engine did not implement it; openbuild's tables can already use it. +Consolidating on dossiq's set alone would have silently broken them. + +**`ANY` meant different things, and neither was DMN's.** openbuild treated it as +`collect` and returned a LIST — a different answer of a different shape. DMN says +a table declaring ANY asserts that its overlapping rules produce the same output, +so a disagreement is a fault in the table. It is implemented that way here, and +the test that pins it fails if it degrades to `first`. + +## Risks + +- 🔴 **A consumer's shipped tables may rely on the old ANY.** openbuild's tables + declaring `any` got a list and now get a single value or an error. That is why + retiring the app-side copies is out of scope: each app adopts this with parity + tests over its own data, not on the strength of this PR. +- ⚠️ **`UNIQUE` throws on no match.** Inherited from dossiq deliberately: a + decision table that matched nothing has not decided anything, and returning an + empty output invites the caller to treat it as a decision. diff --git a/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md new file mode 100644 index 0000000000..40945a5a86 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md @@ -0,0 +1,123 @@ +# shared-decision-tables Specification + +## Purpose + +One home for DMN decision-table evaluation, so the fleet stops building it per +app. Per ADR-065 Decision 6. + +## ADDED Requirements + +### Requirement: REQ-SDT-001 One evaluator, the union of both dialects + +OpenRegister SHALL provide a decision-table evaluator implementing at least +`UNIQUE`, `FIRST`, `COLLECT`, `PRIORITY` and `ANY`, and SHALL refuse any hit +policy it does not implement rather than silently treating it as `FIRST`. + +`PRIORITY` is required: openbuild's evaluator has it and dossiq's does not, so +omitting it would make the consolidation a capability regression. + +#### Scenario: An unimplemented policy is refused + +- **WHEN** a table declares a hit policy the evaluator does not implement +- **THEN** it raises `hit_policy_not_implemented` and decides nothing + +#### Scenario: PRIORITY takes the highest + +- **GIVEN** matching rules with priorities 1, 10 and 5 +- **WHEN** the table is evaluated +- **THEN** the rule with priority 10 wins + +#### Scenario: PRIORITY is deterministic on a tie + +- **GIVEN** two matching rules of equal priority +- **THEN** the earlier one in declaration order wins + +#### Scenario: An absent priority counts as zero + +- **GIVEN** one rule declaring no priority and one declaring 3 +- **THEN** the one declaring 3 wins + +### Requirement: REQ-SDT-002 ANY asserts agreement + +Under `ANY` the system SHALL require every matching rule to produce the same +output, and SHALL raise `hit_policy_violation` when they differ. + +A table declaring ANY asserts that its overlapping rules agree; a disagreement +is a fault in the table, not a choice for the engine. openbuild's evaluator +treated `any` as `collect` and returned a list, which is a different answer of a +different shape. + +#### Scenario: Disagreeing rules are refused + +- **GIVEN** two matching rules with different outputs under ANY +- **THEN** evaluation raises `hit_policy_violation` + +#### Scenario: Agreeing rules return the shared output + +- **GIVEN** two matching rules with the same output under ANY +- **THEN** that output is returned + +### Requirement: REQ-SDT-003 The unary-test grammar is preserved intact + +The evaluator SHALL support the grammar it inherits: wildcards, typed equality, +comparison operators, inclusive and exclusive ranges, set membership, and a +quoted literal that escapes the wildcard. + +It SHALL NOT execute arbitrary code for any expression. + +#### Scenario: Ranges keep their boundary semantics + +- **GIVEN** `[0..25000]` and `(25000..100000]` +- **THEN** 25000 matches the first and 25001 the second + +#### Scenario: Set membership matches any listed value + +- **GIVEN** `in (gering, aanzienlijk)` +- **THEN** `aanzienlijk` matches and `ernstig` does not + +### Requirement: REQ-SDT-004 A three-axis matrix is expressible as a table + +A dense lookup over three inputs SHALL evaluate to its single matching rule +under `UNIQUE`. + +This is the shape dossiq's Landelijke Handhavingsstrategie matrix takes: +(ernst x gedrag x actorType) to one intervention. It is a decision table, and it +belongs on this engine rather than in a bespoke matrix service. + +#### Scenario: The LHS matrix shape evaluates + +- **GIVEN** a table over severity, behaviour and actorType +- **WHEN** evaluated with a triple matching exactly one rule +- **THEN** that rule's intervention is returned with its id + +### Requirement: REQ-SDT-005 A declared type is aliased onto the evaluator's own vocabulary + +Decision tables in the fleet were authored against two type vocabularies. openbuild's shipped +credit-approval table declares `integer`; this evaluator's own vocabulary is +`string | number | boolean | date`, and an unrecognised type falls back to `string`. + +Measured, that fallback is harmless for numbers and dates: the unary-test grammar parses its operand +and compares numerically, so `>=18` against 25 matches whether the column was called `number` or +`string`, and ISO dates compare correctly either way. It is NOT harmless for booleans. A real PHP +`true` coerced to a string becomes `"1"`, which does not match a cell reading `true`, so a column +declaring `bool` rather than `boolean` silently stops matching. + +The evaluator SHALL map the common spellings of its types onto them. The boolean alias closes the +silent mismatch above; the numeric and date aliases align the vocabularies so a table means the same +thing in either dialect, and are defensive rather than corrective. + +#### Scenario: A `bool` column matches a real boolean + +- **GIVEN** an input column declaring `bool` and the value `true` +- **WHEN** it is evaluated against a cell reading `true` +- **THEN** the rule matches, where before the value was stringified to `"1"` and did not + +#### Scenario: The alias set covers the spellings actually in use + +- **GIVEN** columns declaring `int`, `integer`, `long`, `float`, `double` or `decimal` +- **THEN** each is evaluated as `number` + +#### Scenario: An unrecognised type still falls back to string + +- **GIVEN** a column declaring a type in neither vocabulary +- **THEN** it is evaluated as `string`, as before this change diff --git a/openspec/changes/shared-decision-table-evaluator/tasks.md b/openspec/changes/shared-decision-table-evaluator/tasks.md new file mode 100644 index 0000000000..a8a45fbe38 --- /dev/null +++ b/openspec/changes/shared-decision-table-evaluator/tasks.md @@ -0,0 +1,30 @@ +# Tasks: shared-decision-table-evaluator + +## Implementation Tasks + +### Task 1: Move the grammar +- **spec_ref**: `openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md#requirement-req-sdt-003-the-unary-test-grammar-is-preserved-intact` +- **files**: `lib/Service/Dmn/UnaryTestEvaluator.php`, `lib/Service/Dmn/DecisionEvaluationException.php` +- **acceptance_criteria**: + - GIVEN the ported grammar THEN ranges, sets, comparisons and the quoted-literal escape behave as they did in dossiq + - GIVEN the move THEN it is a MOVE: the file is dossiq's, renamed and re-namespaced, not retyped +- [x] Implement +- [x] Test + +### Task 2: The consolidated table evaluator +- **spec_ref**: `.../spec.md#requirement-req-sdt-001-one-evaluator-the-union-of-both-dialects` (+ REQ-SDT-002) +- **files**: `lib/Service/Dmn/DecisionTableEvaluator.php` +- **acceptance_criteria**: + - GIVEN the hit policies THEN the list is the UNION of both apps', including openbuild's PRIORITY + - GIVEN PRIORITY THEN the highest wins, ties break by declaration order, and an absent priority is zero + - GIVEN ANY THEN disagreeing rules are refused and agreeing ones return the shared output + - GIVEN an unimplemented policy THEN it is refused, never treated as FIRST +- [x] Implement +- [x] Test — mutation-checked: dropping PRIORITY, degrading ANY to FIRST, and making the tie non-deterministic each turn the suite red + +### Task 3: The consumers adopt it +- **spec_ref**: deferred +- **acceptance_criteria**: + - Deliberately out of scope. openbuild and dossiq each adopt this in their own change, with parity tests over their own shipped tables — openbuild's `any` tables in particular change shape. Deleting a working evaluator on the strength of a new one that has not yet run its data would be the same mistake in the other direction. +- [ ] Implement +- [ ] Test diff --git a/package-lock.json b/package-lock.json index 39ca5aaadd..e6967e9cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,14 +14,14 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/dialogs": "^7.4.1", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", "apexcharts": "^7.0.0", - "css-loader": "^7.1.1", + "css-loader": "^7.1.5", "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", @@ -29,12 +29,12 @@ "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", - "vue": "^3.5.0", + "vue": "^3.5.42", "vue-codemirror6": "^1.6.1", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", "vue-material-design-icons": "^5.2.0", - "vue-router": "^5.2.0", + "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", "zod": "^4.4.3" }, @@ -54,8 +54,8 @@ "@playwright/test": "^1.49.0", "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", - "@types/node": "^20.17.23", - "@typescript-eslint/parser": "^8.67.0", + "@types/node": "^26.4.0", + "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", @@ -66,7 +66,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "postcss": "^8.4.31", "postcss-html": "^1.8.1", @@ -87,9 +87,125 @@ "npm": "^11.0.0" }, "peerDependencies": { - "vue": "^3.5.0" + "vue": "^3.5.42" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@asyncapi/specs": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", @@ -2173,9 +2289,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.24.3", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.24.3.tgz", - "integrity": "sha512-Hflys+AxGNNSkDxuMf04RdFzijhWDKtcRJAHtmtYLj6ntwoIT51k0rfRju6S5GuTNF+daJDUv9tHDVVXb9ZIWw==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.0.tgz", + "integrity": "sha512-64IS6VrAp4c+Hr9AAS9/z6AzznmaPfbO4GrZtmOX57dLCpJORkn5M/wSKpAC6ZG+gUHTJeXDUiabut9bV0kRnQ==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2239,7 +2355,7 @@ "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", "marked": "^12.0.0", - "pinia": "^2.0.0 || ^3.0.0", + "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", "vue-material-design-icons": "^5.0.0" @@ -2282,6 +2398,26 @@ "@yr/monotone-cubic-spline": "^1.0.3" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/css-calc": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", @@ -3555,6 +3691,244 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.0.tgz", + "integrity": "sha512-825vac4Dmysbn2kU7VUQPoKuj/HNUpSTgv98KCByMOSPvHuj1/HpVZeLRsP/itDB2HFiDcoTUrsg8fSu3PxKBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.0", + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/jsdom": "*", + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/expect-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.0", + "picomatch": "^4.0.3", + "pretty-format": "30.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -3600,6 +3974,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -3616,6 +4000,46 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -4533,15 +4957,16 @@ } }, "node_modules/@nextcloud/dialogs": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-7.4.1.tgz", - "integrity": "sha512-M8q1v14rTMdjTyv5HOZrpBpa5M+qnqnjoFCeeohmKvt69xVW9Xag6R5InQhBQh3zfsYEaQ4mg3shn3kKBx9qxg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@nextcloud/dialogs/-/dialogs-7.5.0.tgz", + "integrity": "sha512-2gmv0V3ObphxRd7ZvXwTsPvn/XSf2vummdXb2LRFpkoSamWbk+41SWv7cnDQPC+Ve9p46WebDuqyG+wbefkhEw==", "license": "AGPL-3.0-or-later", "dependencies": { "@mdi/js": "^7.4.47", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", @@ -4549,12 +4974,10 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^0.4.0", - "@nextcloud/vue": "^9.8.2", - "@types/toastify-js": "^1.12.4", - "@vueuse/core": "^14.3.0", - "p-queue": "^9.3.1", - "toastify-js": "^1.12.0", - "vue": "^3.5.39", + "@nextcloud/vue": "^9.9.0", + "@vueuse/core": "^14.4.0", + "p-queue": "^9.3.3", + "vue": "^3.5.41", "webdav": "^5.10.0" }, "engines": { @@ -5058,21 +5481,6 @@ "node": ">= 14" } }, - "node_modules/@npmcli/agent/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6812,16 +7220,6 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7073,9 +7471,9 @@ } }, "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { @@ -7084,12 +7482,6 @@ "parse5": "^7.0.0" } }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7119,12 +7511,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/prop-types": { @@ -7239,12 +7631,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/toastify-js": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.4.tgz", - "integrity": "sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==", - "license": "MIT" - }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -7346,16 +7732,39 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -7365,9 +7774,129 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@typescript-eslint/project-service": { @@ -7814,48 +8343,6 @@ "rfdc": "^1.4.1" } }, - "node_modules/@vue/reactivity": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", - "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.41" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", - "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/shared": "3.5.41" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", - "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/runtime-core": "3.5.41", - "@vue/shared": "3.5.41", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", - "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/shared": "3.5.41" - } - }, "node_modules/@vue/shared": { "version": "3.5.41", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", @@ -8295,14 +8782,6 @@ "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", "license": "MIT" }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -8383,17 +8862,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8404,19 +8872,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -10519,9 +10974,9 @@ } }, "node_modules/css-loader": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", - "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.5.tgz", + "integrity": "sha512-Q7iAfQkU2twNBryKX/vGAlE+GAmkF7quhSzAGNK8fBimxk3+tqg245rH52UPb9dioBolBc8rP0ihmHBaDTJQBA==", "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", @@ -10591,33 +11046,20 @@ "node": ">=4" } }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true, - "license": "MIT" - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -10737,18 +11179,17 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { @@ -11231,20 +11672,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -11775,39 +12202,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", @@ -13681,16 +14075,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-entities": { @@ -13794,18 +14188,27 @@ } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/http-proxy-middleware": { @@ -15138,42 +15541,248 @@ "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.0.tgz", + "integrity": "sha512-VVHN/G3zrxsQR398jvMalM76ALX6YBAftsYLCGtTeKRmz4f42YJAP05AGpk0VF5SLtoHdkfKunYGfnKPnmcEOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.5.0", + "@jest/environment-jsdom-abstract": "30.5.0", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz", + "integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-mock": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/expect-utils": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.0.tgz", + "integrity": "sha512-5j0ztPxSy3McUJihjkDdCyCfjvT2hxykFTWsgEBZKB8qsw9ALdCiGTpTRH5gnf/d+qI4SflYUJ0dWNbzjQCWbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz", + "integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.0", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.5.0", + "jest-mock": "30.5.0", + "jest-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.0.tgz", + "integrity": "sha512-s1N+79S4Yp9ZgklCauZXi+YPJdCdtStNYQT32stuD6EeQaIBGHoUfyj2P0YWy8RmuQfaJboO+ulxEvEheR/POQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz", + "integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.0", + "picomatch": "^4.0.3", + "pretty-format": "30.5.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.0.tgz", + "integrity": "sha512-bP5MHZpkYrV7xpV+yvhl36DPcXoEmTR57Un5EACcdVpMY7mpkDefCBq+V4mhcjE/3rwUajT6OTrcJTN7EwN1BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.5.0", + "@jest/types": "30.5.0", + "@types/node": "*", + "jest-util": "30.5.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.0.tgz", + "integrity": "sha512-lzU4aGUWaS+2X/B0CmgheDasfnsVlRfZh/rNQxB9b9s8cSYUq5BcqdQA95ld+KqJXBUVVt1sqnMQ2T3OxIalmg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", + "@jest/types": "30.5.0", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, - "peerDependencies": { - "canvas": "^2.5.0" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz", + "integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { @@ -15662,44 +16271,38 @@ } }, "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -15707,14 +16310,28 @@ } } }, - "node_modules/jsdom/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/jsep": { @@ -15731,6 +16348,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -15770,6 +16388,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -17924,9 +18543,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "version": "2.2.25", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz", + "integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg==", "dev": true, "license": "MIT" }, @@ -19283,19 +19902,6 @@ "node": ">=10" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -20169,6 +20775,13 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -22575,6 +23188,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -22628,12 +23261,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/toastify-js": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", - "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==", - "license": "MIT" - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -22646,42 +23273,29 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tree-dump": { @@ -23187,6 +23801,31 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/typescript-event-target": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", @@ -23233,9 +23872,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -23767,16 +24406,16 @@ "peer": true }, "node_modules/vue": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", - "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-sfc": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/server-renderer": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" @@ -23949,17 +24588,16 @@ } }, "node_modules/vue-router": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", - "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.0.tgz", + "integrity": "sha512-a2PBXX9yfkS58JzSJALUffgDa09lEzKmCcEnLaepoIr88L+9hEnsgEQkcadJGID+vtHa0gFpVo064zmylA9fcg==", "license": "MIT", "dependencies": { - "@babel/generator": "^8.0.0", "@vue-macros/common": "^3.1.3", "@vue/devtools-api": "^8.1.5", "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", - "json5": "^2.2.3", + "confbox": "^0.2.4", "local-pkg": "^1.2.1", "magic-string": "^0.30.21", "mlly": "^1.8.2", @@ -23970,8 +24608,7 @@ "scule": "^1.3.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", - "unplugin-utils": "^0.3.2", - "yaml": "^2.9.0" + "unplugin-utils": "^0.3.2" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -23998,69 +24635,6 @@ } } }, - "node_modules/vue-router/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/vue-router/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/vue-router/node_modules/@vue/devtools-api": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", @@ -24115,6 +24689,113 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vue/node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/vue/node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/vue/node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/vue/node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/vue/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/vue3-apexcharts": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz", @@ -24144,26 +24825,16 @@ "license": "MIT" }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/walker": { @@ -24558,9 +25229,9 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", @@ -24568,31 +25239,31 @@ "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { @@ -24943,21 +25614,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index 71ed5a18e2..71e8f90a5f 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ ], "sideEffects": true, "peerDependencies": { - "vue": "^3.5.0" + "vue": "^3.5.42" }, "dependencies": { "@codemirror/lang-json": "^6.0.1", @@ -68,14 +68,14 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/dialogs": "^7.4.1", + "@nextcloud/dialogs": "^7.5.0", "@nextcloud/initial-state": "^3.0.0", "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.9.0", "@vueuse/core": "^14.3.0", "apexcharts": "^7.0.0", - "css-loader": "^7.1.1", + "css-loader": "^7.1.5", "dexie": "^4.4.5", "dompurify": "^3.4.14", "gridstack": "^13.2.0", @@ -83,12 +83,12 @@ "path-browserify": "^1.0.1", "pinia": "^3.0.4", "style-loader": "^4.0.0", - "vue": "^3.5.0", + "vue": "^3.5.42", "vue-codemirror6": "^1.6.1", "vue-draggable-plus": "^0.6.0", "vue-loading-overlay": "^6.0.6", "vue-material-design-icons": "^5.2.0", - "vue-router": "^5.2.0", + "vue-router": "^5.3.0", "vue3-apexcharts": "~1.8.0", "zod": "^4.4.3" }, @@ -108,8 +108,8 @@ "@playwright/test": "^1.49.0", "@stoplight/spectral-cli": "^6.15.0", "@types/jest": "^29.5.12", - "@types/node": "^20.17.23", - "@typescript-eslint/parser": "^8.67.0", + "@types/node": "^26.4.0", + "@typescript-eslint/parser": "^8.68.0", "@vue/test-utils": "^2.4.4", "@vue/vue3-jest": "^29.2.6", "axe-core": "^4.10.0", @@ -120,7 +120,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-webpack-plugin": "^6.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.5.0", "jest-transform-stub": "^2.0.0", "postcss": "^8.4.31", "postcss-html": "^1.8.1", diff --git a/tests/Db/OrganisationTenantScopeIntegrationTest.php b/tests/Db/OrganisationTenantScopeIntegrationTest.php new file mode 100644 index 0000000000..5fa8aa5a58 --- /dev/null +++ b/tests/Db/OrganisationTenantScopeIntegrationTest.php @@ -0,0 +1,186 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Db; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Uuid; + +/** + * What `findLocalTenants()` actually returns, against a real database. + * + * The job-level tests mock the mapper, so they prove the jobs ASK the right + * question. This proves the query ANSWERS it — and in particular the one thing + * a mock cannot show: + * + * 🔴 A NULL `is_local_tenant` must count as a tenant. The column arrives by + * migration, so every row written before it holds NULL. A plain `= true` would + * make every pre-existing tenant invisible to all three tenant jobs at once — + * tenants would silently stop being deprovisioned, purged and metered, and the + * jobs would report success over an empty list. + * + * @group DB + */ +class OrganisationTenantScopeIntegrationTest extends TestCase { + + /** + * The mapper under test. + * + * @var OrganisationMapper + */ + private OrganisationMapper $mapper; + + /** + * Uuids created by this test, dropped in tearDown. + * + * @var array + */ + private array $created = []; + + /** + * Resolve the mapper from the server container. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->mapper = \OC::$server->get(OrganisationMapper::class); + + }//end setUp() + + /** + * Remove everything this test created. + * + * @return void + */ + protected function tearDown(): void { + foreach ($this->created as $uuid) { + try { + $this->mapper->delete($this->mapper->findByUuid($uuid)); + } catch (\Throwable $e) { + // Already gone, or never created; nothing to clean. + } + } + + parent::tearDown(); + + }//end tearDown() + + /** + * Store an organisation. + * + * @param string $name Its name. + * @param boolean|null $isLocal The tenancy flag, or null to leave it unset. + * + * @return string The uuid. + */ + private function make(string $name, ?bool $isLocal): string { + $uuid = (string)Uuid::v4(); + + $org = new Organisation(); + $org->setUuid($uuid); + $org->setName($name . '-' . substr($uuid, 0, 8)); + $org->setSlug('scope-' . substr($uuid, 0, 8)); + $org->setStatus('active'); + if ($isLocal !== null) { + $org->setIsLocalTenant($isLocal); + } + + $this->mapper->insert($org); + $this->created[] = $uuid; + + return $uuid; + + }//end make() + + /** + * The uuids findLocalTenants returns, across pages. + * + * @return array The uuids. + */ + private function tenantUuids(): array { + $out = []; + foreach ($this->mapper->findLocalTenants(limit: 500, filters: ['status' => 'active']) as $org) { + $out[] = (string)$org->getUuid(); + } + + return $out; + + }//end tenantUuids() + + /** + * A counterparty is excluded; a tenant and a pre-migration row are not. + * + * @return void + */ + public function testACounterpartyIsExcludedAndANullIsNot(): void { + $tenant = $this->make('tenant', true); + $legacy = $this->make('legacy', null); + $partner = $this->make('partner', false); + + $found = $this->tenantUuids(); + + $this->assertContains($tenant, $found, 'an explicit tenant is a tenant'); + $this->assertContains( + $legacy, + $found, + 'a row predating the column must still count as a tenant, or every existing tenant silently stops being processed' + ); + $this->assertNotContains($partner, $found, 'a counterparty is not a tenant of this installation'); + + }//end testACounterpartyIsExcludedAndANullIsNot() + + /** + * findAll still returns everything, so the admin surface is unchanged. + * + * The narrowing is deliberately confined to the tenant path: an + * organisation list that hid counterparties would hide the ketenpartners + * the federation exists to work with. + * + * @return void + */ + public function testFindAllStillReturnsCounterparties(): void { + $partner = $this->make('partner', false); + + $found = []; + foreach ($this->mapper->findAll(limit: 500, filters: ['status' => 'active']) as $org) { + $found[] = (string)$org->getUuid(); + } + + $this->assertContains($partner, $found); + + }//end testFindAllStillReturnsCounterparties() + + /** + * A counterparty carries the peer instance it is a tenant of. + * + * @return void + */ + public function testACounterpartyCarriesItsRemoteInstance(): void { + $uuid = (string)Uuid::v4(); + $org = new Organisation(); + $org->setUuid($uuid); + $org->setName('Gemeente Elders-' . substr($uuid, 0, 8)); + $org->setSlug('elders-' . substr($uuid, 0, 8)); + $org->setStatus('active'); + $org->setIsLocalTenant(false); + $org->setRemoteInstanceUrl('https://fed2.example'); + $this->mapper->insert($org); + $this->created[] = $uuid; + + $stored = $this->mapper->findByUuid($uuid); + + $this->assertFalse($stored->getIsLocalTenant()); + $this->assertSame('https://fed2.example', $stored->getRemoteInstanceUrl()); + + }//end testACounterpartyCarriesItsRemoteInstance() + +}//end class diff --git a/tests/Unit/BackgroundJob/TenantJobsScopeTest.php b/tests/Unit/BackgroundJob/TenantJobsScopeTest.php new file mode 100644 index 0000000000..c5d41b1f07 --- /dev/null +++ b/tests/Unit/BackgroundJob/TenantJobsScopeTest.php @@ -0,0 +1,260 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\BackgroundJob; + +use DateTime; +use OCA\OpenRegister\BackgroundJob\TenantDeprovisionJob; +use OCA\OpenRegister\BackgroundJob\TenantPurgeJob; +use OCA\OpenRegister\BackgroundJob\TenantUsageSyncJob; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Db\TenantUsageMapper; +use OCA\OpenRegister\Service\TenantLifecycleService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * What the three tenant background jobs consider a tenant. + * + * PINNING TESTS, written before the behaviour changes. They exist because a + * scoping mistake here does not throw: `TenantPurgeJob` PERMANENTLY DELETES the + * organisation entity, and it selects rows by `status` alone. Nothing in + * `Organisation` distinguishes a tenant of THIS installation from a + * counterparty that is a tenant of another one — so once ketenpartners live in + * the same table, an archived partner is indistinguishable from an archived + * tenant and gets deleted with it. + * + * They were written against the OLD behaviour first, and two of them FAILED + * when the distinction landed — which is the point of writing them first. They + * now assert the guarantee instead: the jobs read through + * `findLocalTenants()`, whose name is the contract, and a counterparty is never + * among the rows they act on. + */ +class TenantJobsScopeTest extends TestCase { + + /** + * Build an organisation. + * + * @param string $uuid Its uuid. + * @param string $status Its lifecycle status. + * @param string|null $deprovisionedAt When it was deprovisioned. + * + * @return Organisation The organisation. + */ + private function organisation(string $uuid, string $status, ?string $deprovisionedAt = null): Organisation { + $org = new Organisation(); + $org->setUuid($uuid); + $org->setStatus($status); + $org->setName('Org ' . $uuid); + if ($deprovisionedAt !== null) { + $org->setDeprovisionedAt(new DateTime($deprovisionedAt)); + } + + return $org; + + }//end organisation() + + /** + * Invoke a job's protected run(). + * + * @param object $job The job. + * + * @return void + */ + private function runJob(object $job): void { + $run = new ReflectionMethod($job, 'run'); + $run->setAccessible(true); + $run->invoke($job, null); + + }//end runJob() + + /** + * The purge job reads through the tenant-scoped path. + * + * It still filters by status — that part is unchanged — but it can no longer + * see a counterparty at all, because `findLocalTenants()` excludes one + * before the status filter is applied. The mapper is mocked here, so what + * this pins is that the job ASKS the right question; that the query answers + * it correctly is pinned by OrganisationMapperTenantScopeTest. + * + * @return void + */ + public function testPurgeReadsThroughTheTenantScopedPath(): void { + $seenFilters = []; + $deleted = []; + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturnCallback( + function (int $limit = 50, int $offset = 0, ?array $filters = []) use (&$seenFilters): array { + $seenFilters = ($filters ?? []); + + return [$this->organisation('partner-1', 'archived', '2020-01-01')]; + } + ); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $job = new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ); + + $this->runJob($job); + + $this->assertSame(['status' => TenantLifecycleService::STATUS_ARCHIVED], $seenFilters); + $this->assertSame(['partner-1'], $deleted, 'a LOCAL TENANT that is archived is still purged'); + + }//end testPurgeReadsThroughTheTenantScopedPath() + + /** + * The purge job spares a row that has not been deprovisioned. + * + * @return void + */ + public function testPurgeSparesARowWithNoDeprovisionedAt(): void { + $deleted = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturn([$this->organisation('org-1', 'archived')]); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $this->runJob( + new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame([], $deleted); + + }//end testPurgeSparesARowWithNoDeprovisionedAt() + + /** + * The purge job spares a row still inside the retention window. + * + * @return void + */ + public function testPurgeSparesARowInsideRetention(): void { + $deleted = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturn( + [$this->organisation('org-1', 'archived', (new DateTime())->format('c'))] + ); + $mapper->method('delete')->willReturnCallback( + static function (Organisation $org) use (&$deleted): Organisation { + $deleted[] = $org->getUuid(); + + return $org; + } + ); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn('30'); + + $this->runJob( + new TenantPurgeJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $appConfig, + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame([], $deleted); + + }//end testPurgeSparesARowInsideRetention() + + /** + * The deprovision job reads through the tenant-scoped path too. + * + * @return void + */ + public function testDeprovisionReadsThroughTheTenantScopedPath(): void { + $seenFilters = []; + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findLocalTenants')->willReturnCallback( + function (int $limit = 50, int $offset = 0, ?array $filters = []) use (&$seenFilters): array { + $seenFilters = ($filters ?? []); + + return []; + } + ); + + $this->runJob( + new TenantDeprovisionJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantLifecycleService::class), + $this->createMock(LoggerInterface::class), + ) + ); + + $this->assertSame(['status' => TenantLifecycleService::STATUS_DEPROVISIONING], $seenFilters); + + }//end testDeprovisionReadsThroughTheTenantScopedPath() + + /** + * The usage-sync job does nothing at all without APCu. + * + * Pinned as the honest current behaviour rather than asserting a filter the + * test environment cannot reach: the job returns before it queries anything + * when `apcu_enabled()` is false, which it is here. Asserting the filter + * would have been a test that passes because the code never ran. + * + * @return void + */ + public function testUsageSyncDoesNothingWithoutApcu(): void { + if (function_exists('apcu_enabled') === true && apcu_enabled() === true) { + $this->markTestSkipped('APCu is enabled here, so the early return under test cannot be observed.'); + } + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->expects($this->never())->method('findLocalTenants'); + + $this->runJob( + new TenantUsageSyncJob( + $this->createMock(ITimeFactory::class), + $mapper, + $this->createMock(TenantUsageMapper::class), + $this->createMock(LoggerInterface::class), + ) + ); + + $this->addToAssertionCount(1); + + }//end testUsageSyncDoesNothingWithoutApcu() + +}//end class diff --git a/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php b/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php new file mode 100644 index 0000000000..1e8df4c35d --- /dev/null +++ b/tests/Unit/Db/MagicMapper/MagicOrganizationHandlerScopeTest.php @@ -0,0 +1,185 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Db\MagicMapper; + +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * @covers \OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler + */ +class MagicOrganizationHandlerScopeTest extends TestCase { + + /** + * Build a handler for a caller with the given admin flag and active orgs. + * + * A non-null user is always supplied: `isSystemContext()` treats a NULL + * user under the CLI SAPI as a trusted system context, and PHPUnit runs + * under CLI, so a null user would silently make every case SCOPE_ALL. + * + * @param bool $isAdmin Whether the caller is in the admin group. + * @param array $orgUuids Active organisation UUIDs. + * + * @return MagicOrganizationHandler + */ + private function handler(bool $isAdmin, array $orgUuids): MagicOrganizationHandler { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('someone'); + + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($user); + + $groups = $this->createMock(IGroupManager::class); + $groups->method('getUserGroupIds')->willReturn($isAdmin === true ? ['admin'] : ['users']); + + $appConfig = $this->createMock(IAppConfig::class); + // No multitenancy config ⇒ SaaS mode off. + $appConfig->method('getValueString')->willReturn(''); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willThrowException(new \RuntimeException('no service')); + + $handler = $this->getMockBuilder(MagicOrganizationHandler::class) + ->setConstructorArgs([$session, $groups, $appConfig, $container, $this->createMock(LoggerInterface::class)]) + ->onlyMethods(['getActiveOrganizationUuids']) + ->getMock(); + $handler->method('getActiveOrganizationUuids')->willReturn($orgUuids); + + return $handler; + }//end handler() + + /** + * THE REGRESSION. An admin scoped to an organisation must still see rows + * that have no organisation — that is what the aggregation's flattened + * `_organisation = ?` silently dropped. + * + * @return void + */ + public function testAdminWithAnActiveOrgAlsoSeesOrgLessRows(): void { + $scope = $this->handler(true, ['org-a'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN_OR_NULL, $scope['mode']); + $this->assertSame(['org-a'], $scope['uuids']); + }//end testAdminWithAnActiveOrgAlsoSeesOrgLessRows() + + /** + * A non-admin is confined to their own organisation, and org-less rows + * stay invisible to them. Widening this would be a tenant leak. + * + * @return void + */ + public function testNonAdminIsConfinedToTheirOwnOrganisation(): void { + $scope = $this->handler(false, ['org-a'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + $this->assertSame(['org-a'], $scope['uuids']); + }//end testNonAdminIsConfinedToTheirOwnOrganisation() + + /** + * Several active organisations stay a set — a renderer that collapsed + * them to one would under-report the rest. + * + * @return void + */ + public function testMultipleActiveOrganisationsAreAllCarried(): void { + $scope = $this->handler(false, ['org-a', 'org-b'])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + $this->assertSame(['org-a', 'org-b'], $scope['uuids']); + }//end testMultipleActiveOrganisationsAreAllCarried() + + /** + * An admin with no active organisation sees exactly the org-less rows. + * + * @return void + */ + public function testAdminWithoutAnActiveOrganisationSeesOnlyOrgLessRows(): void { + $scope = $this->handler(true, [])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_NULL_ONLY, $scope['mode']); + $this->assertSame([], $scope['uuids']); + }//end testAdminWithoutAnActiveOrganisationSeesOnlyOrgLessRows() + + /** + * Fail-closed: a non-admin with no active organisation sees nothing at + * all, rather than everything. + * + * @return void + */ + public function testNonAdminWithoutAnActiveOrganisationSeesNothing(): void { + $scope = $this->handler(false, [])->resolveOrganizationScope(); + + $this->assertSame(MagicOrganizationHandler::SCOPE_NONE, $scope['mode']); + }//end testNonAdminWithoutAnActiveOrganisationSeesNothing() + + /** + * The admin bypass lifts the boundary entirely, but only when the caller + * opts into it — the default must stay scoped. + * + * @return void + */ + public function testAdminBypassIsOptInAndLiftsTheBoundary(): void { + $handler = $this->handler(true, ['org-a']); + + $this->assertSame( + MagicOrganizationHandler::SCOPE_ALL, + $handler->resolveOrganizationScope(adminBypassEnabled: true)['mode'] + ); + $this->assertSame( + MagicOrganizationHandler::SCOPE_IN_OR_NULL, + $handler->resolveOrganizationScope()['mode'], + 'the bypass must not leak into the default call' + ); + }//end testAdminBypassIsOptInAndLiftsTheBoundary() + + /** + * The bypass is for admins only. + * + * @return void + */ + public function testAdminBypassDoesNothingForANonAdmin(): void { + $scope = $this->handler(false, ['org-a'])->resolveOrganizationScope(adminBypassEnabled: true); + + $this->assertSame(MagicOrganizationHandler::SCOPE_IN, $scope['mode']); + }//end testAdminBypassDoesNothingForANonAdmin() + +}//end class diff --git a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php index 38370321fb..1d718f98d8 100644 --- a/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationJoinAndCompositeGroupByTest.php @@ -36,6 +36,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -1729,6 +1730,7 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->translationHandler, languageService: $this->languageService, ); @@ -1775,4 +1777,24 @@ private function makeRegister(string $slug, array $schemaIds = []): Register { $register->setSchemas($schemaIds); return $register; }//end makeRegister() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php index b85214edb2..06ae5b83b4 100644 --- a/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php @@ -38,6 +38,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -128,6 +129,7 @@ public function __toString(): string { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class) ); @@ -431,4 +433,24 @@ public function testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand() $this->assertStringContainsString('carries no schemas at all', $caught->getMessage()); $this->assertStringContainsString('occ openregister:registers:relink-schemas', $caught->getMessage()); }//end testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php index 4ce0aab7ac..b236f0a3bf 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerAdhocCacheTest.php @@ -23,6 +23,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\Register; @@ -113,6 +114,7 @@ protected function setUp(): void { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->translationHandler, languageService: $this->languageService, ); @@ -239,6 +241,7 @@ public function testGroupedTranslatableKeysProjectToNegotiatedLanguage(): void { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -323,4 +326,24 @@ private function makeRegister(): Register { return $register; }//end makeRegister() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php index 10bd096c67..727423753a 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerCumulativeTest.php @@ -39,6 +39,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; @@ -364,6 +365,7 @@ function () use ($cursor) { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); @@ -521,9 +523,30 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); }//end makeRunner() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php index ed21dffd08..285e7ff351 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiFieldGroupByTest.php @@ -30,6 +30,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -382,6 +383,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -400,4 +402,24 @@ private function makeSchema(): Schema { $schema->setId(1); return $schema; } + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php index 9aa799056e..44488496ca 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiMetricTest.php @@ -27,6 +27,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -414,6 +415,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -439,4 +441,24 @@ private function makeSchema(): Schema { ); return $schema; }//end makeSchema() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php index e88db8dc6d..f96454995b 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerMultiValueFilterTest.php @@ -41,6 +41,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use OCA\OpenRegister\Db\MagicMapper; @@ -577,6 +578,7 @@ private function makeRunner(IDBConnection $db, MagicMapper $magicMapper): Aggreg permissionHandler: $permissionHandler, userSession: $userSession, organisationService: $organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $translationHandler, languageService: $languageService, ); @@ -616,4 +618,24 @@ private function makeTagsSchema(): Schema { ); return $schema; }//end makeTagsSchema() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php index 9229744ed0..c07f42081b 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerNativeBucketTest.php @@ -23,6 +23,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; @@ -399,9 +400,30 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); }//end makeRunner() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php index d77ec5b5b8..59b3912496 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php @@ -27,6 +27,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\Register; use OCA\OpenRegister\Db\RegisterMapper; @@ -118,6 +119,7 @@ private function makeRunner(): AggregationRunner { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class) ); @@ -238,4 +240,24 @@ public function testLoadSchemaUnknownRefRethrowsAsNotFoundRuntimeException(): vo $this->privateMethod($runner, 'loadSchema')->invoke($runner, 'does-not-exist'); }//end testLoadSchemaUnknownRefRethrowsAsNotFoundRuntimeException() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php index db86abf063..4b59107233 100644 --- a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php @@ -35,6 +35,7 @@ namespace Unit\Service\Aggregation; +use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler; use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Register; @@ -135,6 +136,7 @@ function (string|int $id, array $schemaIds): ?Schema { permissionHandler: $this->permissionHandler, userSession: $this->userSession, organisationService: $this->organisationService, + organizationHandler: $this->orgHandlerScopedTo('__no_active_org__'), translationHandler: $this->createMock(TranslationHandler::class), languageService: $this->createMock(LanguageService::class), ); @@ -860,4 +862,24 @@ public function testDerivedMetricCannotReadALaterAlias(): void { ); }//end testDerivedMetricCannotReadALaterAlias() + + /** + * A MagicOrganizationHandler that reports the caller scoped to exactly one + * organisation. The fixtures in these tests seed rows carrying that same + * value in `_organisation`, so the rendered predicate matches them — which + * is what the old hard-coded `_organisation = :activeOrg` did implicitly. + * + * @param string $orgUuid The organisation the caller is scoped to. + * + * @return MagicOrganizationHandler + */ + private function orgHandlerScopedTo(string $orgUuid): MagicOrganizationHandler { + $handler = $this->createMock(MagicOrganizationHandler::class); + $handler->method('resolveOrganizationScope')->willReturn( + ['mode' => MagicOrganizationHandler::SCOPE_IN, 'uuids' => [$orgUuid]] + ); + + return $handler; + }//end orgHandlerScopedTo() + }//end class diff --git a/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php new file mode 100644 index 0000000000..1c0c1a9d99 --- /dev/null +++ b/tests/Unit/Service/Dmn/DecisionTableEvaluatorTest.php @@ -0,0 +1,401 @@ + + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace Unit\Service\Dmn; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\DecisionTableEvaluator; +use PHPUnit\Framework\TestCase; + +/** + * The consolidated decision-table evaluator. + * + * The fleet built decision tables TWICE — openbuild 2026-06-05, dossiq + * 2026-07-15, six weeks apart, neither knowing the other existed — and the + * newer one shipped FEWER hit policies. ADR-065 Decision 6 consolidates them + * here, and says the consolidation must take openbuild's `priority` because + * dropping it would be a capability regression. + * + * So these tests are mostly about the seams between the two dialects: the + * policies that came from one side, and the ones whose meaning differed. + */ +class DecisionTableEvaluatorTest extends TestCase { + + /** + * The evaluator under test. + * + * @return DecisionTableEvaluator The evaluator. + */ + private function evaluator(): DecisionTableEvaluator { + return new DecisionTableEvaluator(); + + }//end evaluator() + + /** + * A table over one string input and one string output. + * + * @param string $hitPolicy The hit policy. + * @param array> $rules The rules. + * + * @return array The table. + */ + private function table(string $hitPolicy, array $rules): array { + return [ + 'hitPolicy' => $hitPolicy, + 'inputs' => [['name' => 'severity', 'type' => 'string']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => $rules, + ]; + + }//end table() + + /** + * A single rule. + * + * @param string $id Its id. + * @param string $match The unary test. + * @param string $output The output value. + * @param integer|null $priority Its priority, or null to omit the key. + * + * @return array The rule. + */ + private function rule(string $id, string $match, string $output, ?int $priority = null): array { + $rule = ['id' => $id, 'inputEntries' => [$match], 'outputEntries' => [$output]]; + if ($priority !== null) { + $rule['priority'] = $priority; + } + + return $rule; + + }//end rule() + + /** + * FIRST takes the earliest matching rule. + * + * @return void + */ + public function testFirstTakesTheEarliestMatch(): void { + $table = $this->table('FIRST', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['a'], $out['matchedRuleIds']); + + }//end testFirstTakesTheEarliestMatch() + + /** + * UNIQUE refuses an overlap rather than picking one. + * + * @return void + */ + public function testUniqueRefusesAnOverlap(): void { + $table = $this->table('UNIQUE', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + }//end testUniqueRefusesAnOverlap() + + /** + * COLLECT returns every matching rule's output. + * + * @return void + */ + public function testCollectReturnsEveryMatch(): void { + $table = $this->table('COLLECT', [ + $this->rule('a', 'ernstig', 'bestuursdwang'), + $this->rule('b', '-', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame(['bestuursdwang', 'waarschuwing'], $out['outputs']['intervention']); + + }//end testCollectReturnsEveryMatch() + + /** + * 🔴 PRIORITY is openbuild's capability, and the reason this is a + * consolidation rather than a relocation. dossiq's engine did not have it. + * + * @return void + */ + public function testPriorityTakesTheHighest(): void { + $table = $this->table('PRIORITY', [ + $this->rule('low', '-', 'waarschuwing', 1), + $this->rule('high', 'ernstig', 'bestuursdwang', 10), + $this->rule('mid', '-', 'herstelactie', 5), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'ernstig']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['high'], $out['matchedRuleIds']); + + }//end testPriorityTakesTheHighest() + + /** + * PRIORITY breaks a tie by declaration order, deterministically. + * + * @return void + */ + public function testPriorityBreaksTiesByDeclarationOrder(): void { + $table = $this->table('PRIORITY', [ + $this->rule('first', '-', 'waarschuwing', 5), + $this->rule('second', '-', 'herstelactie', 5), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('waarschuwing', $out['outputs']['intervention']); + + }//end testPriorityBreaksTiesByDeclarationOrder() + + /** + * A rule with no priority counts as zero rather than as undefined. + * + * @return void + */ + public function testAnAbsentPriorityCountsAsZero(): void { + $table = $this->table('PRIORITY', [ + $this->rule('unset', '-', 'waarschuwing'), + $this->rule('set', '-', 'bestuursdwang', 3), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + + }//end testAnAbsentPriorityCountsAsZero() + + /** + * 🔴 ANY requires the matching rules to AGREE. + * + * openbuild's evaluator treated `any` as `collect` and returned a list — + * a different answer of a different shape. DMN says a table declaring ANY + * asserts its overlapping rules produce the same output, so a disagreement + * is a fault in the table rather than a choice to make silently. + * + * @return void + */ + public function testAnyRefusesDisagreeingRules(): void { + $table = $this->table('ANY', [ + $this->rule('a', '-', 'waarschuwing'), + $this->rule('b', '-', 'bestuursdwang'), + ]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testAnyRefusesDisagreeingRules() + + /** + * ANY accepts agreeing rules and returns the shared output. + * + * @return void + */ + public function testAnyAcceptsAgreeingRules(): void { + $table = $this->table('ANY', [ + $this->rule('a', '-', 'waarschuwing'), + $this->rule('b', 'gering', 'waarschuwing'), + ]); + + $out = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('waarschuwing', $out['outputs']['intervention']); + + }//end testAnyAcceptsAgreeingRules() + + /** + * An unimplemented hit policy is refused rather than treated as FIRST. + * + * @return void + */ + public function testAnUnknownHitPolicyIsRefused(): void { + $table = $this->table('OUTPUT-ORDER', [$this->rule('a', '-', 'waarschuwing')]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testAnUnknownHitPolicyIsRefused() + + /** + * The LHS matrix shape: three axes, one intervention, UNIQUE. + * + * This is the table dossiq's Landelijke Handhavingsstrategie becomes — a + * dense (ernst x gedrag x actorType) lookup — expressed in the shared + * vocabulary rather than in a bespoke matrix service. + * + * @return void + */ + public function testTheLhsMatrixShapeEvaluates(): void { + $table = [ + 'hitPolicy' => 'UNIQUE', + 'inputs' => [ + ['name' => 'severity', 'type' => 'string'], + ['name' => 'behaviour', 'type' => 'string'], + ['name' => 'actorType', 'type' => 'string'], + ], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [ + [ + 'id' => 'gering-goedwillend-burger', + 'inputEntries' => ['gering', 'goedwillend', 'burger'], + 'outputEntries' => ['waarschuwing'], + ], + [ + 'id' => 'ernstig-crimineel-bedrijf', + 'inputEntries' => ['ernstig', 'crimineel', 'bedrijf'], + 'outputEntries' => ['bestuursdwang'], + ], + ], + ]; + + $out = $this->evaluator()->evaluate( + $table, + ['severity' => 'ernstig', 'behaviour' => 'crimineel', 'actorType' => 'bedrijf'] + ); + + $this->assertSame('bestuursdwang', $out['outputs']['intervention']); + $this->assertSame(['ernstig-crimineel-bedrijf'], $out['matchedRuleIds']); + + }//end testTheLhsMatrixShapeEvaluates() + + /** + * A numeric range still works, so the ported grammar came across intact. + * + * @return void + */ + public function testTheRangeGrammarSurvivedThePort(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'amount', 'type' => 'number']], + 'outputs' => [['name' => 'band', 'type' => 'string']], + 'rules' => [ + ['id' => 'low', 'inputEntries' => ['[0..25000]'], 'outputEntries' => ['low']], + ['id' => 'high', 'inputEntries' => ['(25000..100000]'], 'outputEntries' => ['high']], + ], + ]; + + $this->assertSame('low', $this->evaluator()->evaluate($table, ['amount' => 25000])['outputs']['band']); + $this->assertSame('high', $this->evaluator()->evaluate($table, ['amount' => 25001])['outputs']['band']); + + }//end testTheRangeGrammarSurvivedThePort() + + /** + * Set membership survived the port too. + * + * @return void + */ + public function testSetMembershipSurvivedThePort(): void { + $table = $this->table('FIRST', [ + $this->rule('set', 'in (gering, aanzienlijk)', 'licht'), + $this->rule('rest', '-', 'zwaar'), + ]); + + $this->assertSame('licht', $this->evaluator()->evaluate($table, ['severity' => 'aanzienlijk'])['outputs']['intervention']); + $this->assertSame('zwaar', $this->evaluator()->evaluate($table, ['severity' => 'ernstig'])['outputs']['intervention']); + + }//end testSetMembershipSurvivedThePort() + + /** + * No matching rule is an error, not an empty answer. + * + * @return void + */ + public function testNoMatchIsAnError(): void { + $table = $this->table('UNIQUE', [$this->rule('a', 'ernstig', 'bestuursdwang')]); + + $this->expectException(DecisionEvaluationException::class); + + $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + }//end testNoMatchIsAnError() + + /** + * A `bool` column matches a real boolean. + * + * This is the one alias that changes an answer rather than a spelling. + * Without the map `bool` falls back to `string`, PHP `true` is coerced to + * `"1"`, and a cell reading `true` silently stops matching. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testABoolColumnMatchesARealBoolean(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'consented', 'type' => 'bool']], + 'outputs' => [['name' => 'decision', 'type' => 'string']], + 'rules' => [['id' => 'yes', 'inputEntries' => ['true'], 'outputEntries' => ['proceed']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['consented' => true]); + + $this->assertSame('proceed', $result['outputs']['decision']); + + }//end testABoolColumnMatchesARealBoolean() + + /** + * Every numeric spelling in fleet use maps onto `number`. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testEveryNumericAliasIsTreatedAsANumber(): void { + foreach (['int', 'integer', 'long', 'float', 'double', 'decimal', 'INTEGER'] as $declared) { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'score', 'type' => $declared]], + 'outputs' => [['name' => 'band', 'type' => 'string']], + 'rules' => [['id' => 'high', 'inputEntries' => ['>=600'], 'outputEntries' => ['ok']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['score' => 700]); + + $this->assertSame('ok', $result['outputs']['band'], 'type ' . $declared . ' should compare numerically'); + } + + }//end testEveryNumericAliasIsTreatedAsANumber() + + /** + * A type in neither vocabulary still falls back to `string`. + * + * The alias map is additive; it must not turn an unknown type into an error. + * + * @return void + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + public function testAnUnrecognisedTypeStillFallsBackToString(): void { + $table = [ + 'hitPolicy' => 'FIRST', + 'inputs' => [['name' => 'severity', 'type' => 'wingdings']], + 'outputs' => [['name' => 'intervention', 'type' => 'string']], + 'rules' => [['id' => 'a', 'inputEntries' => ['gering'], 'outputEntries' => ['brief']]], + ]; + + $result = $this->evaluator()->evaluate($table, ['severity' => 'gering']); + + $this->assertSame('brief', $result['outputs']['intervention']); + + }//end testAnUnrecognisedTypeStillFallsBackToString() + +}//end class diff --git a/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php b/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php new file mode 100644 index 0000000000..223f894dd2 --- /dev/null +++ b/tests/Unit/Service/Dmn/UnaryTestEvaluatorTest.php @@ -0,0 +1,364 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + * + * @spec openspec/changes/shared-decision-table-evaluator/specs/shared-decision-tables/spec.md + */ + +declare(strict_types=1); + +namespace Unit\Service\Dmn; + +use OCA\OpenRegister\Service\Dmn\DecisionEvaluationException; +use OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\Dmn\UnaryTestEvaluator + * + * @uses \OCA\OpenRegister\Service\Dmn\DecisionEvaluationException + */ +class UnaryTestEvaluatorTest extends TestCase { + + /** + * The evaluator under test. + * + * @var UnaryTestEvaluator + */ + private UnaryTestEvaluator $evaluator; + + /** + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->evaluator = new UnaryTestEvaluator(); + }//end setUp() + + // ------------------------------------------------------------------ + // Wildcard + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testEmptyExpressionIsWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '', value: 'anything', type: 'string')); + }//end testEmptyExpressionIsWildcard() + + /** + * @return void + */ + public function testDashExpressionIsWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '-', value: 12345.0, type: 'number')); + }//end testDashExpressionIsWildcard() + + /** + * @return void + */ + public function testQuotedDashIsLiteralNotWildcard(): void { + self::assertTrue($this->evaluator->matches(expression: '"-"', value: '-', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '"-"', value: 'anything-else', type: 'string')); + }//end testQuotedDashIsLiteralNotWildcard() + + // ------------------------------------------------------------------ + // Comparison operators + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testLessThan(): void { + self::assertTrue($this->evaluator->matches(expression: '< 10', value: 5.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '< 10', value: 10.0, type: 'number')); + }//end testLessThan() + + /** + * @return void + */ + public function testLessThanOrEqual(): void { + self::assertTrue($this->evaluator->matches(expression: '<= 10', value: 10.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '<= 10', value: 10.5, type: 'number')); + }//end testLessThanOrEqual() + + /** + * @return void + */ + public function testGreaterThan(): void { + self::assertTrue($this->evaluator->matches(expression: '> 10', value: 11.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '> 10', value: 10.0, type: 'number')); + }//end testGreaterThan() + + /** + * @return void + */ + public function testGreaterThanOrEqual(): void { + self::assertTrue($this->evaluator->matches(expression: '>= 10', value: 10.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '>= 10', value: 9.999, type: 'number')); + }//end testGreaterThanOrEqual() + + /** + * @return void + */ + public function testEquals(): void { + self::assertTrue($this->evaluator->matches(expression: '= gold', value: 'gold', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '= gold', value: 'silver', type: 'string')); + }//end testEquals() + + /** + * @return void + */ + public function testNotEquals(): void { + self::assertTrue($this->evaluator->matches(expression: '!= gold', value: 'silver', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: '!= gold', value: 'gold', type: 'string')); + }//end testNotEquals() + + /** + * @return void + */ + public function testOperatorWithNoOperandIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '>= ', value: 1.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('invalid_expression', $e->getErrorCode()); + throw $e; + } + }//end testOperatorWithNoOperandIsInvalidExpression() + + // ------------------------------------------------------------------ + // Ranges + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testInclusiveRangeBoundsMatch(): void { + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 0.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 25000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[0..25000]', value: 12500.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '[0..25000]', value: 25000.01, type: 'number')); + }//end testInclusiveRangeBoundsMatch() + + /** + * @return void + */ + public function testExclusiveRangeBoundsDoNotMatch(): void { + self::assertFalse($this->evaluator->matches(expression: '(25000..40000)', value: 25000.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '(25000..40000)', value: 40000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '(25000..40000)', value: 30000.0, type: 'number')); + }//end testExclusiveRangeBoundsDoNotMatch() + + /** + * @return void + */ + public function testMixedRangeBoundaries(): void { + self::assertFalse($this->evaluator->matches(expression: '(25000..40000]', value: 25000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '(25000..40000]', value: 40000.0, type: 'number')); + self::assertTrue($this->evaluator->matches(expression: '[25000..40000)', value: 25000.0, type: 'number')); + self::assertFalse($this->evaluator->matches(expression: '[25000..40000)', value: 40000.0, type: 'number')); + }//end testMixedRangeBoundaries() + + /** + * @return void + */ + public function testUnbalancedRangeIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[1..', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + // No `..` match at all falls through to bare-literal coercion, + // which for a non-numeric literal like "[1.." on a number type + // surfaces as type_mismatch — still a clear, typed error, never + // a silent match/non-match. + self::assertContains($e->getErrorCode(), ['invalid_expression', 'type_mismatch']); + throw $e; + } + }//end testUnbalancedRangeIsInvalidExpression() + + /** + * @return void + */ + public function testMissingRangeBoundIsInvalidExpression(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[1..]', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('invalid_expression', $e->getErrorCode()); + throw $e; + } + }//end testMissingRangeBoundIsInvalidExpression() + + /** + * @return void + */ + public function testNonNumericRangeBoundIsTypeMismatch(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->matches(expression: '[abc..100]', value: 5.0, type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testNonNumericRangeBoundIsTypeMismatch() + + // ------------------------------------------------------------------ + // Sets + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testSetMembershipUnquoted(): void { + self::assertTrue($this->evaluator->matches(expression: 'in (gold, silver, bronze)', value: 'silver', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: 'in (gold, silver, bronze)', value: 'platinum', type: 'string')); + }//end testSetMembershipUnquoted() + + /** + * @return void + */ + public function testSetMembershipQuotedWithCommas(): void { + self::assertTrue($this->evaluator->matches(expression: 'in ("a b", "c,d")', value: 'c,d', type: 'string')); + self::assertTrue($this->evaluator->matches(expression: 'in ("a b", "c,d")', value: 'a b', type: 'string')); + }//end testSetMembershipQuotedWithCommas() + + /** + * @return void + */ + public function testSetMembershipCaseInsensitivePrefix(): void { + self::assertTrue($this->evaluator->matches(expression: 'IN (1, 2, 3)', value: 2.0, type: 'number')); + }//end testSetMembershipCaseInsensitivePrefix() + + // ------------------------------------------------------------------ + // Bare literal + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testBareLiteralString(): void { + self::assertTrue($this->evaluator->matches(expression: 'gold', value: 'gold', type: 'string')); + self::assertFalse($this->evaluator->matches(expression: 'gold', value: 'silver', type: 'string')); + }//end testBareLiteralString() + + /** + * @return void + */ + public function testBareLiteralNumber(): void { + self::assertTrue($this->evaluator->matches(expression: '42', value: 42.0, type: 'number')); + }//end testBareLiteralNumber() + + /** + * @return void + */ + public function testBareLiteralBooleanTrue(): void { + self::assertTrue($this->evaluator->matches(expression: 'true', value: true, type: 'boolean')); + self::assertFalse($this->evaluator->matches(expression: 'true', value: false, type: 'boolean')); + }//end testBareLiteralBooleanTrue() + + /** + * @return void + */ + public function testBareLiteralBooleanFalse(): void { + self::assertTrue($this->evaluator->matches(expression: 'false', value: false, type: 'boolean')); + }//end testBareLiteralBooleanFalse() + + // ------------------------------------------------------------------ + // coerce() — type coercion matrix + // ------------------------------------------------------------------ + + /** + * @return void + */ + public function testCoerceString(): void { + self::assertSame('42', $this->evaluator->coerce(value: 42, type: 'string')); + }//end testCoerceString() + + /** + * @return void + */ + public function testCoerceNumberFromString(): void { + self::assertSame(42.5, $this->evaluator->coerce(value: '42.5', type: 'number')); + }//end testCoerceNumberFromString() + + /** + * @return void + */ + public function testCoerceNumberRejectsNonNumeric(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'not-a-number', type: 'number'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceNumberRejectsNonNumeric() + + /** + * @return void + */ + public function testCoerceBooleanFromString(): void { + self::assertTrue($this->evaluator->coerce(value: 'true', type: 'boolean')); + self::assertFalse($this->evaluator->coerce(value: 'false', type: 'boolean')); + }//end testCoerceBooleanFromString() + + /** + * @return void + */ + public function testCoerceBooleanRejectsGarbage(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'maybe', type: 'boolean'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceBooleanRejectsGarbage() + + /** + * @return void + */ + public function testCoerceDateFromIsoString(): void { + $timestamp = $this->evaluator->coerce(value: '2026-01-01T00:00:00+00:00', type: 'date'); + self::assertIsInt($timestamp); + }//end testCoerceDateFromIsoString() + + /** + * @return void + */ + public function testCoerceDateRejectsUnparsable(): void { + $this->expectException(DecisionEvaluationException::class); + try { + $this->evaluator->coerce(value: 'not-a-date', type: 'date'); + } catch (DecisionEvaluationException $e) { + self::assertSame('type_mismatch', $e->getErrorCode()); + throw $e; + } + }//end testCoerceDateRejectsUnparsable() + + /** + * @return void + */ + public function testDateRangeComparison(): void { + $value = $this->evaluator->coerce(value: '2026-06-15', type: 'date'); + self::assertTrue($this->evaluator->matches(expression: '[2026-01-01..2026-12-31]', value: $value, type: 'date')); + self::assertFalse($this->evaluator->matches(expression: '[2027-01-01..2027-12-31]', value: $value, type: 'date')); + }//end testDateRangeComparison() +}//end class