diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bcb3f795d3..73ff7a4171 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -222,6 +222,7 @@ use OCA\OpenRegister\Service\ObjectSource\FederatedObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\FilesObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\GroupObjectSourceProvider; +use OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider; use OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry; use OCA\OpenRegister\Service\ObjectSource\TablesColumnMapper; use OCA\OpenRegister\Service\ObjectSource\TablesObjectSourceProvider; @@ -1474,6 +1475,18 @@ function (ContainerInterface $container) { } ); + $context->registerService( + OrganisationObjectSourceProvider::class, + function (ContainerInterface $container) { + return new OrganisationObjectSourceProvider( + organisationMapper: $container->get('OCA\OpenRegister\Db\OrganisationMapper'), + userSession: $container->get('OCP\IUserSession'), + groupManager: $container->get('OCP\IGroupManager'), + logger: $container->get('Psr\Log\LoggerInterface') + ); + } + ); + $context->registerService( ContactsObjectSourceProvider::class, function (ContainerInterface $container) { @@ -4436,6 +4449,7 @@ private function bootObjectSourceProviders($server): void { CalDavVtodoObjectSourceProvider::class, UserDirectoryObjectSourceProvider::class, GroupObjectSourceProvider::class, + OrganisationObjectSourceProvider::class, ContactsObjectSourceProvider::class, CalendarEventObjectSourceProvider::class, FilesObjectSourceProvider::class, diff --git a/lib/Repair/SeedDirectoryVirtualSchemas.php b/lib/Repair/SeedDirectoryVirtualSchemas.php index fcabacced1..a97fdf97bb 100644 --- a/lib/Repair/SeedDirectoryVirtualSchemas.php +++ b/lib/Repair/SeedDirectoryVirtualSchemas.php @@ -67,6 +67,30 @@ class SeedDirectoryVirtualSchemas implements IRepairStep { 'id' => ['type' => 'string', 'title' => 'Group ID', 'description' => 'The Nextcloud group id (gid).'], 'displayName' => ['type' => 'string', 'title' => 'Display name', 'description' => 'The group display name.'], ], + // The identity facet of an OpenRegister organisation, and nothing else. + // This set MUST stay in step with + // {@see \OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider}'s + // projection: a property declared here and not projected reads as + // permanently empty, and one projected but not declared is discarded by + // the store without a word. + // + // Tenancy administration (quota, users, groups, authorization) is + // deliberately absent. This schema exists so another record can REFERENCE + // an organisation, not so anyone can configure one through the object API. + 'nc-organisation' => [ + 'id' => ['type' => 'string', 'title' => 'Organisation ID', 'description' => 'The organisation uuid.'], + 'name' => ['type' => 'string', 'title' => 'Name', 'description' => 'The organisation name.'], + 'description' => ['type' => 'string', 'title' => 'Description', 'description' => 'A description of the organisation.'], + 'summary' => ['type' => 'string', 'title' => 'Summary', 'description' => 'A short summary.'], + 'oin' => ['type' => 'string', 'title' => 'OIN', 'description' => 'Organisatie-identificatienummer.'], + 'tooi' => ['type' => 'string', 'title' => 'TOOI', 'description' => 'TOOI register identifier.'], + 'rsin' => ['type' => 'string', 'title' => 'RSIN', 'description' => 'Rechtspersonen en Samenwerkingsverbanden Informatienummer.'], + 'kvk' => ['type' => 'string', 'title' => 'KVK', 'description' => 'Chamber of Commerce number.'], + 'pki' => ['type' => 'string', 'title' => 'PKI', 'description' => 'PKIoverheid certificate identifier.'], + 'image' => ['type' => 'string', 'title' => 'Image', 'description' => 'A logo or image URL.'], + 'type' => ['type' => 'string', 'title' => 'Type', 'description' => 'What kind of organisation this row describes.'], + 'registrationStatus' => ['type' => 'string', 'title' => 'Registration status', 'description' => 'Registration lifecycle state.'], + ], ]; /** diff --git a/lib/Service/ObjectSource/NcEntitySemanticMap.php b/lib/Service/ObjectSource/NcEntitySemanticMap.php index ce707d31f5..61f114d775 100644 --- a/lib/Service/ObjectSource/NcEntitySemanticMap.php +++ b/lib/Service/ObjectSource/NcEntitySemanticMap.php @@ -84,6 +84,22 @@ final class NcEntitySemanticMap { 'requiredApp' => null, 'application' => 'openregister', ], + // OpenRegister's own organisation, projected so a leaf schema can point a + // `{"$ref": ...}` at it. Several apps declared their own `organization` + // SCHEMA precisely because there was nothing here to reference, and a + // schema slug is global per organisation, so those copies collide. + // + // `nc-`-prefixed for the reason the app-gated rows below are: it must not + // collide with the leaf-app `organization` schemas it exists to replace, + // which have to keep working until each app has migrated off them. + 'organisation' => [ + 'register' => self::DIRECTORY_REGISTER, + 'schema' => 'nc-organisation', + 'schemaOrg' => 'schema:Organization', + 'provider' => 'organisation-source', + 'requiredApp' => null, + 'application' => 'openregister', + ], // App-gated rows — each lives on its OWN app-named register (application = // register slug) so the ADR-048 app-enabled gate degrades the projection // when the backing app is uninstalled. Schemas are `nc-`-prefixed to avoid diff --git a/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php b/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php new file mode 100644 index 0000000000..500ea96592 --- /dev/null +++ b/lib/Service/ObjectSource/OrganisationObjectSourceProvider.php @@ -0,0 +1,375 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Service\ObjectSource; + +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\Schema; +use OCP\IGroupManager; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Projects each organisation as a read-only virtual object. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ +class OrganisationObjectSourceProvider implements ObjectSourceProvider { + + /** + * The properties the projection exposes. + * + * Deliberately the identity facet and nothing else. The quota, authorization + * and lifecycle columns are tenancy administration: an object projection is + * for referencing an organisation from another record, not for managing one, + * and exposing them here would put tenant configuration behind the object API. + * + * @var array + */ + private const PROJECTED = [ + 'name', + 'description', + 'summary', + 'oin', + 'tooi', + 'rsin', + 'kvk', + 'pki', + 'image', + 'type', + 'registrationStatus', + ]; + + /** + * Wire the mapper and the acting-user services. + * + * @param OrganisationMapper $organisationMapper The organisation mapper. + * @param IUserSession $userSession The acting user's session. + * @param IGroupManager $groupManager Group manager, for the admin check. + * @param LoggerInterface $logger Logger. + * + * @return void + */ + public function __construct( + private readonly OrganisationMapper $organisationMapper, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * {@inheritDoc} + * + * @return string The provider id. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function getId(): string { + return 'organisation-source'; + }//end getId() + + /** + * {@inheritDoc} + * + * Organisations are OpenRegister's own, so this provider is always available. + * + * @return bool Always true. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function isEnabled(): bool { + return true; + }//end isEnabled() + + /** + * {@inheritDoc} + * + * Returns null when the organisation is absent OR the acting user may not + * read it, so the two are indistinguishable and the projection cannot be used + * to enumerate the instance's tenants. + * + * A merged-away organisation resolves to its survivor, so a reference stored + * before a merge keeps pointing at a real record. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param string $id The organisation uuid. + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity|null The virtual object, or null when absent or denied. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function find(Register $register, Schema $schema, string $id, array $config = []): ?ObjectEntity { + try { + $organisation = $this->organisationMapper->findByUuidFollowingMerge(uuid: $id); + } catch (Throwable $e) { + $this->logger->debug('[ObjectSource:organisation-source] could not read organisation: ' . $e->getMessage()); + return null; + } + + if ($this->mayRead(organisation: $organisation) === false) { + return null; + } + + return $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + }//end find() + + /** + * {@inheritDoc} + * + * Honours `filters.search` / `_search`, `limit` and `offset`. An admin sees + * every organisation; anyone else sees only the ones they belong to. + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param array $query Query (filters/search/limit/offset). + * @param array $config The object-source config block (unused). + * + * @return ObjectEntity[] The matching virtual objects. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $config reserved for future scoping options. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function findAll(Register $register, Schema $schema, array $query = [], array $config = []): array { + $objects = []; + foreach ($this->readOrganisations(query: $query) as $organisation) { + $objects[] = $this->toObjectEntity(register: $register, schema: $schema, organisation: $organisation); + } + + return $objects; + }//end findAll() + + /** + * {@inheritDoc} + * + * @param Register $register The register the schema belongs to. + * @param Schema $schema The sourced schema. + * @param array $query Query (filters/search). + * @param array $config The object-source config block (unused). + * + * @return int The number of matching virtual objects. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + public function count(Register $register, Schema $schema, array $query = [], array $config = []): int { + return count($this->findAll(register: $register, schema: $schema, query: $query, config: $config)); + }//end count() + + /** + * The projected fields of one organisation. + * + * Empty values are omitted rather than written as null, so a consumer can + * tell "this organisation has no OIN" from "this projection does not carry + * OINs". + * + * @param Organisation $organisation The organisation. + * + * @return array The object body. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-carries-the-identity-facet-only-req-orp-102 + */ + public static function project(Organisation $organisation): array { + // Read the SERIALISED entity, not `method_exists()` + a derived getter. + // Organisation's accessors are magic (`Entity::__call`), so + // `method_exists($organisation, 'getName')` is FALSE and every field + // would have been skipped, leaving a projection carrying nothing but an + // id. That shipped-looking-fine failure is what the tests caught. + $serialised = $organisation->jsonSerialize(); + + $data = ['id' => (string)$organisation->getUuid()]; + + foreach (self::PROJECTED as $property) { + $value = ($serialised[$property] ?? null); + if ($value === null || $value === '') { + continue; + } + + $data[$property] = $value; + } + + return $data; + }//end project() + + /** + * Whether the acting user may read this organisation's projection. + * + * @param Organisation $organisation The organisation being read. + * + * @return bool True when the acting user may read it. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-is-not-an-enumeration-oracle-req-orp-103 + */ + private function mayRead(Organisation $organisation): bool { + $acting = $this->userSession->getUser(); + if ($acting === null) { + return false; + } + + try { + if ($this->groupManager->isAdmin($acting->getUID()) === true) { + return true; + } + + return $organisation->hasUser($acting->getUID()); + } catch (Throwable $e) { + return false; + } + }//end mayRead() + + /** + * The organisations visible to the acting user, failing closed to an empty list. + * + * @param array $query Query (filters/search/limit/offset). + * + * @return array The visible organisations. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-is-not-an-enumeration-oracle-req-orp-103 + */ + private function readOrganisations(array $query): array { + $acting = $this->userSession->getUser(); + if ($acting === null) { + return []; + } + + $search = (string)($query['filters']['search'] ?? $query['_search'] ?? $query['search'] ?? ''); + $limit = (int)($query['limit'] ?? 200); + $offset = (int)($query['offset'] ?? 0); + + try { + if ($this->groupManager->isAdmin($acting->getUID()) === true) { + $organisations = $this->organisationMapper->findAll(limit: $limit, offset: $offset, filters: []); + return array_values(self::matching(organisations: $organisations, search: $search)); + } + + $organisations = $this->organisationMapper->findByUserId($acting->getUID()); + } catch (Throwable $e) { + $this->logger->warning('[ObjectSource:organisation-source] could not list organisations: ' . $e->getMessage()); + return []; + } + + return array_values(self::matching(organisations: $organisations, search: $search)); + }//end readOrganisations() + + /** + * Filter organisations by a search term over the fields a person would type. + * + * A merged-away organisation is excluded: it is not a usable reference target, + * and offering it in a picker invites a reference to a record that no longer + * owns anything. + * + * @param array $organisations The candidates. + * @param string $search The search term, or ''. + * + * @return array The matches. + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-the-projection-carries-the-identity-facet-only-req-orp-102 + */ + public static function matching(array $organisations, string $search): array { + $live = array_filter( + $organisations, + static function ($organisation) { + return ($organisation instanceof Organisation && $organisation->isMerged() === false); + } + ); + + if ($search === '') { + return $live; + } + + $needle = strtolower($search); + + return array_filter( + $live, + static function (Organisation $organisation) use ($needle) { + foreach ([$organisation->getName(), $organisation->getOin(), $organisation->getRsin(), $organisation->getKvk()] as $field) { + if ($field !== null && str_contains(strtolower((string)$field), $needle) === true) { + return true; + } + } + + return false; + } + ); + }//end matching() + + /** + * Map an organisation onto a non-persisted virtual ObjectEntity. + * + * @param Register $register The register. + * @param Schema $schema The sourced schema. + * @param Organisation $organisation The organisation. + * + * @return ObjectEntity The virtual object (never saved). + * + * @spec openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md#requirement-an-organisation-is-addressable-as-an-object-req-orp-101 + */ + private function toObjectEntity(Register $register, Schema $schema, Organisation $organisation): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid((string)$organisation->getUuid()); + $entity->setRegister((string)$register->getId()); + $entity->setSchema((string)$schema->getId()); + $entity->setObject(self::project(organisation: $organisation)); + + return $entity; + }//end toObjectEntity() +}//end class diff --git a/openspec/changes/organisations-are-objects/proposal.md b/openspec/changes/organisations-are-objects/proposal.md new file mode 100644 index 0000000000..d78117d794 --- /dev/null +++ b/openspec/changes/organisations-are-objects/proposal.md @@ -0,0 +1,59 @@ +# An organisation is addressable as an object + +## Why + +Several apps grew their own `organization` SCHEMA, and measuring one showed why. +`publication.organization` and `catalog.organization` are declared as +`{"type": "string", "format": "uuid", "$ref": "organization"}`. A `$ref` +resolves against a SCHEMA, and OpenRegister's Organisation is an ENTITY with no +object projection, so there was nothing for that reference to point at. Each app +declared its own copy instead. + +A schema slug is global per organisation — `SchemaMapper::find()` matches +`LOWER(slug)` across every app and returns the first row it reaches — so those +copies collide. `organization` is currently claimed by both opencatalogi and +stackiq. + +Adding the identity columns to Organisation (change +`consolidate-organisation-on-or`) made reuse possible at the entity level, and +`openregister:organisations:adopt` moves the rows. Neither gives a leaf schema +something to reference. This does. + +## What changes + +An `nc-organisation` virtual schema on the always-available `directory` +register, served read-only by an `OrganisationObjectSourceProvider`. + +This is not a new mechanism. OpenRegister already projects `nc-user` and +`nc-group` exactly this way, and `nc-group` is even mapped to +`schema:Organization`. The provider follows `GroupObjectSourceProvider` +line for line: the same read-only contract, the same acting-user scoping, the +same "absent and denied are indistinguishable" rule. + +The schema is `nc-`-prefixed for the reason the map already states for the +app-gated rows: it must not collide with the leaf-app `organization` schemas it +exists to replace, which have to keep working until each app has migrated off +them. + +## Three deliberate limits + +**Read-only.** The authoritative record is the Organisation row. A write path +here would be a second way to mutate a tenant, reachable through the object API +and bypassing `OrganisationService`'s lifecycle. + +**The identity facet only.** Quota, users, groups and authorization are tenancy +administration. This schema exists so another record can REFERENCE an +organisation, not so anyone can configure one through the object API. + +**Scoped, and not an enumeration oracle.** An organisation IS the tenant +boundary. An admin sees all of them; anyone else sees only the ones they belong +to; absent and denied both return null. Anonymous callers see nothing. + +## A defect this found + +`project()` was first written with `method_exists($organisation, 'getName')` +before calling the getter. Organisation's accessors are magic +(`Entity::__call`), so `method_exists` is FALSE for every one of them and the +projection would have shipped carrying nothing but an id — a schema that +resolves, returns objects, and is empty. The tests caught it; reading the code +did not. diff --git a/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md b/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md new file mode 100644 index 0000000000..03d48aada0 --- /dev/null +++ b/openspec/changes/organisations-are-objects/specs/organisation-projection/spec.md @@ -0,0 +1,81 @@ +# Organisation projection + +## ADDED Requirements + +### Requirement: An organisation is addressable as an object (REQ-ORP-101) + +An organisation MUST be readable through the object API as a virtual object on +the `directory` register, so a schema property can reference it with a `$ref`. + +The object's id MUST be the organisation uuid, so a reference stored before this +change keeps naming the same record. + +`find()` MUST resolve through a merge chain, so a reference stored before a +merge resolves to the surviving organisation rather than to a row that owns +nothing. + +The projection MUST be read-only. The authoritative record is the Organisation +row, and a write path here would be a second way to mutate a tenant that +bypasses the organisation lifecycle. + +#### Scenario: An organisation is readable as an object + +- **WHEN** the `nc-organisation` schema is listed by an authorised caller +- **THEN** each organisation they may see is returned as an object whose id is + its uuid. + +#### Scenario: A reference to a merged organisation still resolves + +- **GIVEN** an organisation that was merged into another +- **WHEN** it is fetched by its own uuid +- **THEN** the surviving organisation is returned. + +### Requirement: The projection carries the identity facet only (REQ-ORP-102) + +The projection MUST carry name, description, summary, the legal identifiers +(OIN, TOOI, RSIN, KVK, PKI), image, type and registration status. + +It MUST NOT carry quota, users, groups or authorization. Those are tenancy +administration, and this schema exists so another record can reference an +organisation rather than configure one. + +An empty field MUST be omitted rather than emitted as null, so a consumer can +distinguish "this organisation has no OIN" from "this projection does not carry +OINs". + +A merged-away organisation MUST NOT be listed: it owns nothing, and offering it +invites a reference to a record that is not a usable target. + +#### Scenario: Tenancy administration is absent + +- **WHEN** an organisation is projected +- **THEN** the object carries no quota, users, groups or authorization. + +#### Scenario: An organisation with no OIN omits the key + +- **GIVEN** an organisation carrying no OIN +- **WHEN** it is projected +- **THEN** the object has no `oin` key at all. + +### Requirement: The projection is not an enumeration oracle (REQ-ORP-103) + +Reads MUST be scoped to the acting user: an admin sees every organisation, +anyone else sees only the organisations they belong to. + +An organisation that is absent and one the caller may not read MUST be +indistinguishable, so the projection cannot be used to discover which tenants +exist on an instance. + +An anonymous caller MUST see nothing. + +#### Scenario: An anonymous caller sees no organisations + +- **WHEN** an unauthenticated caller lists the schema +- **THEN** the response is empty rather than an error, and reveals no + organisation. + +#### Scenario: A denied organisation is reported as absent + +- **GIVEN** an organisation the acting user does not belong to +- **WHEN** they fetch it by uuid +- **THEN** the result is null, the same as for a uuid that does not exist. diff --git a/openspec/changes/organisations-are-objects/tasks.md b/openspec/changes/organisations-are-objects/tasks.md new file mode 100644 index 0000000000..742710d345 --- /dev/null +++ b/openspec/changes/organisations-are-objects/tasks.md @@ -0,0 +1,64 @@ +# Tasks + +## 1. The projection + +- [x] 1.1 `OrganisationObjectSourceProvider`, read-only, following + `GroupObjectSourceProvider`. +- [x] 1.2 An `nc-organisation` row in `NcEntitySemanticMap` on the always + available `directory` register. +- [x] 1.3 Property definitions in `SeedDirectoryVirtualSchemas`, kept in step + with the provider's projected set — a property declared and not projected + reads as permanently empty, and one projected and not declared is + discarded by the store without a word. +- [x] 1.4 Register the provider in DI and in the provider list. + +## 2. The limits + +- [x] 2.1 Read-only: no write path through the object API. +- [x] 2.2 Identity facet only; no quota, users, groups or authorization. +- [x] 2.3 Scoped to the acting user, with absent and denied indistinguishable. +- [x] 2.4 A merged-away organisation is not offered: it owns nothing, and + listing it invites a reference to a record that is not a usable target. + `find()` still resolves one THROUGH the merge, so a reference stored + before a merge keeps working. + +## 3. Tests + +- [x] 3.1 Ten unit tests covering the projected set, the omissions, the merge + exclusion, search, and the anonymous case. + +## 4. Verified live + +- [x] 4.1 `GET /api/objects/{directory}/{nc-organisation}` returns the + organisation with its identity facet. +- [x] 4.2 The same call unauthenticated returns `total: 0` — not an error, and + not a row. +- [x] 4.3 `find` by uuid returns the organisation. +- [x] 4.4 Seeding on a clean row creates the schema WITH its properties and + links it to the directory register. +- [x] 4.5 THE PREMISE ITSELF, which the rest of this list does not prove. A + leaf property repointed to `{"$ref": "nc-organisation"}` resolves: + `publication.organization` read plain returns the raw uuid, and read with + `_extend[]=organization` inlines the organisation's identity facet from + OpenRegister's own record. That is exactly what the leaf `organization` + schemas were there to provide. + + Worth stating separately because everything above only proves the + projection can be READ. A projection that reads fine and cannot be + `$ref`'d would have satisfied every other check on this list and been + useless for the one thing it exists to do. + +⚠️ `ensureSchema()` reuses an existing schema and never updates it, by design. +A property added to `SCHEMA_PROPERTIES` therefore does NOT reach an instance +that already seeded that schema. Verified by deleting the row and re-running the +repair step. Any future property change needs its own migration. + +## 5. What this unblocks, and does not do + +- [ ] 5.1 Repoint opencatalogi's `publication.organization` and + `catalog.organization` at `nc-organisation`, then retire its own + `organization` schema. +- [ ] 5.2 The same for stackiq, which additionally needs a home for the nine + properties Organisation has no column for (`xml`, `contactsUid`, + `contactpersonen`, `deelnames`, `participants`, `samenwerkingtype`, + `registeredBy`, `publicationDate`/`depublicationDate`). diff --git a/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php b/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php new file mode 100644 index 0000000000..ffe185a2d0 --- /dev/null +++ b/tests/Unit/Service/ObjectSource/OrganisationObjectSourceProviderTest.php @@ -0,0 +1,247 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\ObjectSource; + +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\ObjectSource\OrganisationObjectSourceProvider; +use PHPUnit\Framework\TestCase; + +/** + * Locks what the projection exposes and what it refuses to offer. + */ +class OrganisationObjectSourceProviderTest extends TestCase { + + /** + * Build an organisation. + * + * @param string $uuid The uuid. + * @param string $name The name. + * @param string|null $oin The OIN, if any. + * @param string|null $mergedInto The uuid it was merged into, if any. + * + * @return Organisation The organisation. + */ + private function organisation( + string $uuid, + string $name, + ?string $oin = null, + ?string $mergedInto = null + ): Organisation { + $organisation = new Organisation(); + $organisation->setUuid($uuid); + $organisation->setName($name); + $organisation->setOin($oin); + $organisation->setMergedInto($mergedInto); + + return $organisation; + + }//end organisation() + + /** + * The projection carries the uuid as the object id, so a stored reference + * resolves to the same record it always named. + * + * @return void + */ + public function testTheUuidIsTheObjectId(): void { + $data = OrganisationObjectSourceProvider::project( + organisation: $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht') + ); + + $this->assertSame('org-uuid', $data['id']); + $this->assertSame('Gemeente Utrecht', $data['name']); + + }//end testTheUuidIsTheObjectId() + + /** + * The identity facet is projected, so a leaf app referencing an organisation + * gets the fields it used to keep its own copy of. + * + * @return void + */ + public function testTheIdentityFacetIsProjected(): void { + $organisation = $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht', oin: '00000001002220647000'); + $organisation->setRsin('001234567'); + $organisation->setSummary('A municipality.'); + + $data = OrganisationObjectSourceProvider::project(organisation: $organisation); + + $this->assertSame('00000001002220647000', $data['oin']); + $this->assertSame('001234567', $data['rsin']); + $this->assertSame('A municipality.', $data['summary']); + + }//end testTheIdentityFacetIsProjected() + + /** + * Tenancy administration is NOT projected. An object projection is for + * referencing an organisation, not for managing one, and putting quota or + * authorization behind the object API would make tenant configuration + * readable wherever an object is. + * + * @return void + */ + public function testTenancyAdministrationIsNotProjected(): void { + $organisation = $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht'); + $organisation->setStorageQuota(1024); + $organisation->setUsers(['alice', 'bob']); + $organisation->setAuthorization(['read' => ['admin']]); + + $data = OrganisationObjectSourceProvider::project(organisation: $organisation); + + foreach (['storageQuota', 'bandwidthQuota', 'requestQuota', 'users', 'groups', 'authorization'] as $forbidden) { + $this->assertArrayNotHasKey($forbidden, $data, $forbidden . ' must not be projected'); + } + + }//end testTenancyAdministrationIsNotProjected() + + /** + * An empty field is omitted rather than written as null, so a consumer can + * tell "this organisation has no OIN" from "this projection does not carry + * OINs at all". + * + * @return void + */ + public function testEmptyFieldsAreOmittedRatherThanNulled(): void { + $data = OrganisationObjectSourceProvider::project( + organisation: $this->organisation(uuid: 'org-uuid', name: 'Gemeente Utrecht') + ); + + $this->assertArrayNotHasKey('oin', $data); + $this->assertArrayNotHasKey('rsin', $data); + + }//end testEmptyFieldsAreOmittedRatherThanNulled() + + /** + * A merged-away organisation is not offered. It no longer owns anything, and + * listing it invites a reference to a record that is not a usable target. + * + * @return void + */ + public function testAMergedAwayOrganisationIsNotListed(): void { + $matches = OrganisationObjectSourceProvider::matching( + organisations: [ + $this->organisation(uuid: 'live', name: 'Gemeente Utrecht'), + $this->organisation(uuid: 'dead', name: 'Gemeente Utrecht', oin: null, mergedInto: 'live'), + ], + search: '' + ); + + $this->assertSame(['live'], array_values(array_map(static fn ($o) => $o->getUuid(), $matches))); + + }//end testAMergedAwayOrganisationIsNotListed() + + /** + * Search matches the fields a person would actually type. + * + * @return void + */ + public function testSearchMatchesNameAndLegalIdentifiers(): void { + $organisations = [ + $this->organisation(uuid: 'a', name: 'Gemeente Utrecht', oin: '00000001002220647000'), + $this->organisation(uuid: 'b', name: 'Provincie Zuid-Holland', oin: '99999999999999999999'), + ]; + + $byName = OrganisationObjectSourceProvider::matching(organisations: $organisations, search: 'utrecht'); + $this->assertSame(['a'], array_values(array_map(static fn ($o) => $o->getUuid(), $byName))); + + $byOin = OrganisationObjectSourceProvider::matching(organisations: $organisations, search: '9999999999'); + $this->assertSame(['b'], array_values(array_map(static fn ($o) => $o->getUuid(), $byOin))); + + }//end testSearchMatchesNameAndLegalIdentifiers() + + /** + * Search is case-insensitive, because a picker's user types what they read. + * + * @return void + */ + public function testSearchIsCaseInsensitive(): void { + $matches = OrganisationObjectSourceProvider::matching( + organisations: [$this->organisation(uuid: 'a', name: 'Gemeente Utrecht')], + search: 'GEMEENTE' + ); + + $this->assertCount(1, $matches); + + }//end testSearchIsCaseInsensitive() + + /** + * A search matching nothing returns nothing, rather than falling back to the + * whole list — which would offer every tenant to anyone who typed a typo. + * + * @return void + */ + public function testANonMatchingSearchReturnsNothing(): void { + $this->assertSame( + [], + OrganisationObjectSourceProvider::matching( + organisations: [$this->organisation(uuid: 'a', name: 'Gemeente Utrecht')], + search: 'nothing-matches-this' + ) + ); + + }//end testANonMatchingSearchReturnsNothing() + + /** + * The provider is always available: organisations are OpenRegister's own, so + * there is no backing app that can be uninstalled. + * + * @return void + */ + public function testTheProviderIsAlwaysEnabled(): void { + $provider = new OrganisationObjectSourceProvider( + organisationMapper: $this->createMock(\OCA\OpenRegister\Db\OrganisationMapper::class), + userSession: $this->createMock(\OCP\IUserSession::class), + groupManager: $this->createMock(\OCP\IGroupManager::class), + logger: $this->createMock(\Psr\Log\LoggerInterface::class) + ); + + $this->assertTrue($provider->isEnabled()); + $this->assertSame('organisation-source', $provider->getId()); + + }//end testTheProviderIsAlwaysEnabled() + + /** + * An anonymous caller sees nothing. Absent and denied must be + * indistinguishable, so the projection cannot enumerate the instance's + * tenants. + * + * @return void + */ + public function testAnAnonymousCallerSeesNothing(): void { + $userSession = $this->createMock(\OCP\IUserSession::class); + $userSession->method('getUser')->willReturn(null); + + $provider = new OrganisationObjectSourceProvider( + organisationMapper: $this->createMock(\OCA\OpenRegister\Db\OrganisationMapper::class), + userSession: $userSession, + groupManager: $this->createMock(\OCP\IGroupManager::class), + logger: $this->createMock(\Psr\Log\LoggerInterface::class) + ); + + $this->assertSame( + [], + $provider->findAll( + register: new \OCA\OpenRegister\Db\Register(), + schema: new \OCA\OpenRegister\Db\Schema() + ) + ); + + }//end testAnAnonymousCallerSeesNothing() +}//end class