diff --git a/lib/Command/OrganisationLinkCommand.php b/lib/Command/OrganisationLinkCommand.php
index 218cb8165..a9534733d 100644
--- a/lib/Command/OrganisationLinkCommand.php
+++ b/lib/Command/OrganisationLinkCommand.php
@@ -59,6 +59,7 @@ class OrganisationLinkCommand extends Command {
'no-organisation' => 'No OpenRegister organisation has this uuid.',
'organisation-in-use' => 'This OpenRegister organisation belongs to another organisatie.',
'default-organisation' => 'This is the OpenRegister default organisation, which is never linked.',
+ 'default-unknown' => 'OpenRegister cannot say which organisation is its default, so nothing is linked.',
'not-confirmed' => 'Not linked. Confirm, or run again with --yes.',
];
diff --git a/lib/EventListener/AnonymousRegistrationListener.php b/lib/EventListener/AnonymousRegistrationListener.php
index c75baddad..297887c7a 100644
--- a/lib/EventListener/AnonymousRegistrationListener.php
+++ b/lib/EventListener/AnonymousRegistrationListener.php
@@ -20,8 +20,10 @@
namespace OCA\Stackiq\EventListener;
+use OCA\OpenRegister\Db\OrganisationMapper;
use OCA\OpenRegister\Event\ObjectCreatingEvent;
use OCA\Stackiq\Service\SettingsService;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IUserSession;
@@ -36,6 +38,9 @@
* administrators set them on purpose. So does a create from the command line
* (`occ`, cron), where installs, imports and seed data run without a session.
*
+ * An anonymous create that carries the uuid of an existing OpenRegister organisation
+ * is refused, because the sync links an organisatie to the organisation with its uuid.
+ *
* @template-implements IEventListener
*
* @spec openspec/specs/organisation-registration/spec.md#requirement-req-orr-001-an-organisation-registered-without-an-account-is-pending-until-reviewed
@@ -46,18 +51,21 @@ class AnonymousRegistrationListener implements IEventListener {
*
* @param IUserSession $userSession Tells whether someone is signed in.
* @param SettingsService $settingsService Names the organisation schema.
+ * @param OrganisationMapper $organisationMapper Tells whether an OpenRegister organisation holds a uuid.
* @param bool|null $commandLine Whether this runs from the command line; null reads PHP_SAPI.
*/
public function __construct(
private readonly IUserSession $userSession,
private readonly SettingsService $settingsService,
+ private readonly OrganisationMapper $organisationMapper,
private ?bool $commandLine = null,
) {
$this->commandLine ??= (PHP_SAPI === 'cli');
}//end __construct()
/**
- * Stamp an anonymous organisation create as pending and unpublished.
+ * Stamp an anonymous organisation create as pending and unpublished, or refuse
+ * it when it carries the uuid of an existing OpenRegister organisation.
*
* @param Event $event The dispatched event.
*
@@ -75,6 +83,12 @@ public function handle(Event $event): void {
return;
}
+ if ($this->organisationHolds(uuid: $event->getObject()->getUuid()) === true) {
+ $event->setErrors(['message' => 'This organisation could not be registered.']);
+ $event->stopPropagation();
+ return;
+ }
+
// Merge, never replace: another listener may already have modified the data.
$event->setModifiedData(
array_merge(
@@ -83,4 +97,24 @@ public function handle(Event $event): void {
)
);
}//end handle()
+
+ /**
+ * Whether an OpenRegister organisation holds this uuid.
+ *
+ * @param string|null $uuid The uuid the new object carries, when it has one yet.
+ *
+ * @return bool
+ */
+ private function organisationHolds(?string $uuid): bool {
+ if ($uuid === null || $uuid === '') {
+ return false;
+ }
+
+ try {
+ $this->organisationMapper->findByUuid($uuid);
+ return true;
+ } catch (DoesNotExistException $e) {
+ return false;
+ }
+ }//end organisationHolds()
}//end class
diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php
index d1a0325cd..50581c5c7 100644
--- a/lib/Service/OrganizationSyncService.php
+++ b/lib/Service/OrganizationSyncService.php
@@ -61,6 +61,11 @@
*/
class OrganizationSyncService {
+ /**
+ * App config key prefix under which an administrator's link is recorded, per organisatie uuid.
+ */
+ private const LINK_KEY_PREFIX = 'linked_organisation_';
+
/**
* OrganisatieService instance
*
@@ -990,6 +995,11 @@ private function ensureOrganisationEntity(object $organisationObject, array $run
// Try to find existing organisation entity.
try {
$organisationEntity = $this->organisationMapper->findByUuid($organisationId);
+ if ($this->isBoundOrganisation(organisationEntity: $organisationEntity, run: $run) === false) {
+ $this->refuseUuidConflict(run: $run);
+ return null;
+ }
+
return $this->refreshOrganisationEntity(organisationEntity: $organisationEntity, run: $run);
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
if ($this->slugIsTaken(run: $run) === true) {
@@ -1048,6 +1058,48 @@ private function fullOrganisationObject(object $organisationObject): object {
return $organisationObject;
}//end fullOrganisationObject()
+ /**
+ * Whether this organisation belongs to the organisatie that carries its uuid.
+ *
+ * The sync makes an organisation after the organisatie, so one that is older
+ * only belongs to it when an administrator linked the two. The organisatie's
+ * stored organisation cannot answer this: OpenRegister stamps a new object
+ * with the default or active organisation, which may be the very one asked about.
+ *
+ * @param object $organisationEntity The organisation with the organisatie's uuid.
+ * @param array $run The ensure run (see ensureOrganisationEntity).
+ *
+ * @return bool False when a creation date is unknown and no link is recorded.
+ */
+ private function isBoundOrganisation(object $organisationEntity, array $run): bool {
+ $entityCreated = $organisationEntity->getCreated();
+ $objectCreated = $run['organisationObject']->getCreated();
+ if ($entityCreated instanceof \DateTimeInterface
+ && $objectCreated instanceof \DateTimeInterface
+ && $entityCreated->getTimestamp() >= $objectCreated->getTimestamp()
+ ) {
+ return true;
+ }
+
+ return $this->config->getValueString('stackiq', self::LINK_KEY_PREFIX . $run['organisationId'], '', lazy: true) !== '';
+ }//end isBoundOrganisation()
+
+ /**
+ * Log and count an organisatie left unlinked because it carries the uuid of an
+ * organisation that is not its own.
+ *
+ * @param array $run The ensure run (see ensureOrganisationEntity).
+ *
+ * @return void
+ */
+ private function refuseUuidConflict(array $run): void {
+ $this->logger->warning(
+ 'OrganizationSyncService: an organisatie carries the uuid of an older organisation; nothing is bound, for an administrator to review',
+ ['app' => 'stackiq', 'organisatieId' => $run['organisationId']]
+ );
+ $run['stats']['organisationIdConflicts'][] = $run['organisationId'];
+ }//end refuseUuidConflict()
+
/**
* Bring an existing organisation entity's active flag in line with the object's status,
* mail an organisation that became active, and make the entity the object's owner.
@@ -1107,8 +1159,12 @@ private function refreshOrganisationEntity(object $organisationEntity, array $ru
* Link an organisatie to an existing OpenRegister organisation, as an administrator decides.
*
* The organisation takes the organisatie's uuid, which is what links the two,
- * and becomes the organisatie object's owner. Refused for the reasons
- * checkOrganisationLink() gives.
+ * and becomes the organisatie object's owner. The link is recorded so the sync
+ * keeps the two together although the organisation is older. Refused for the
+ * reasons checkOrganisationLink() gives.
+ *
+ * With both uuids the same it confirms an organisatie the sync refused for
+ * carrying the uuid of an older organisation.
*
* @param string $organisatieUuid The organisatie object's uuid.
* @param string $entityUuid The OpenRegister organisation's uuid.
@@ -1124,6 +1180,7 @@ public function linkOrganisationEntity(string $organisatieUuid, string $entityUu
return $outcome;
}
+ $this->config->setValueString('stackiq', self::LINK_KEY_PREFIX . $organisatieUuid, date('Y-m-d H:i:s'), lazy: true);
$entity = $this->organisationMapper->findByUuid($entityUuid);
$entity->setUuid($organisatieUuid);
$this->organisationMapper->save($entity);
@@ -1141,12 +1198,13 @@ public function linkOrganisationEntity(string $organisatieUuid, string $entityUu
*
* Refused when the organisatie is unknown or already has an organisation, when
* the organisation is unknown, is OpenRegister's default organisation, or is
- * already another organisatie's. Nothing is written.
+ * already another organisatie's, and when OpenRegister cannot say which
+ * organisation is its default. Nothing is written.
*
* @param string $organisatieUuid The organisatie object's uuid.
* @param string $entityUuid The OpenRegister organisation's uuid.
*
- * @return string|null no-organisatie, already-linked, no-organisation,
+ * @return string|null no-organisatie, already-linked, no-organisation, default-unknown,
* default-organisation, organisation-in-use, or null.
*
* @spec openspec/specs/organisatie-service/spec.md
@@ -1163,11 +1221,12 @@ public function checkOrganisationLink(string $organisatieUuid, string $entityUui
*
* @param string $organisatieUuid The organisatie object's uuid.
*
- * @return array The run: register, organizationSchema and organisationObject (null when unknown).
+ * @return array The run: organisationId, register, organizationSchema and organisationObject (null when unknown).
*/
private function linkRun(string $organisatieUuid): array {
$config = $this->settingsService->getVoorzieningenConfig();
$run = [
+ 'organisationId' => $organisatieUuid,
'register' => (string) ($config['register'] ?? ''),
'organizationSchema' => (string) ($config['organisatie_schema'] ?? ''),
];
@@ -1189,31 +1248,57 @@ private function linkRefusal(array $run, array $uuids): ?string {
return 'no-organisatie';
}
- if ($this->organisationExists(uuid: $organisatieUuid) === true) {
+ if ($this->organisatieHasOrganisation(run: $run, entityUuid: $entityUuid) === true) {
return 'already-linked';
}
return $this->organisationRefusal(run: $run, entityUuid: $entityUuid);
}//end linkRefusal()
+ /**
+ * Whether the organisatie already has its organisation.
+ *
+ * An organisation with the organisatie's uuid that does not belong to it can
+ * still be confirmed, by naming that same uuid as the organisation to link.
+ *
+ * @param array $run The link run (see linkRun()).
+ * @param string $entityUuid The OpenRegister organisation's uuid.
+ *
+ * @return bool
+ */
+ private function organisatieHasOrganisation(array $run, string $entityUuid): bool {
+ try {
+ $holder = $this->organisationMapper->findByUuid($run['organisationId']);
+ } catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
+ return false;
+ }
+
+ return $entityUuid !== $run['organisationId'] || $this->isBoundOrganisation(organisationEntity: $holder, run: $run) === true;
+ }//end organisatieHasOrganisation()
+
/**
* Why this OpenRegister organisation may not take an organisatie's uuid, or null when it may.
*
* @param array $run The link run (see linkRun()).
* @param string $entityUuid The OpenRegister organisation's uuid.
*
- * @return string|null no-organisation, default-organisation, organisation-in-use, or null.
+ * @return string|null no-organisation, default-unknown, default-organisation, organisation-in-use, or null.
*/
private function organisationRefusal(array $run, string $entityUuid): ?string {
if ($this->organisationExists(uuid: $entityUuid) === false) {
return 'no-organisation';
}
- if ($this->container->get('OCA\OpenRegister\Service\OrganisationService')->getDefaultOrganisationUuid() === $entityUuid) {
+ $defaultUuid = $this->container->get('OCA\OpenRegister\Service\OrganisationService')->getDefaultOrganisationUuid();
+ if ($defaultUuid === null || $defaultUuid === '') {
+ return 'default-unknown';
+ }
+
+ if ($defaultUuid === $entityUuid) {
return 'default-organisation';
}
- if ($this->findOrganisatie(run: $run, uuid: $entityUuid) !== null) {
+ if ($entityUuid !== $run['organisationId'] && $this->findOrganisatie(run: $run, uuid: $entityUuid) !== null) {
return 'organisation-in-use';
}
diff --git a/openspec/specs/organisation-registration/spec.md b/openspec/specs/organisation-registration/spec.md
index f6a0439ad..aa3cfe841 100644
--- a/openspec/specs/organisation-registration/spec.md
+++ b/openspec/specs/organisation-registration/spec.md
@@ -1,7 +1,7 @@
# organisation-registration Specification
## Purpose
-How an organisation that registers itself through the public portal (board SkPubliek, "Uw organisatie aanmelden") is stored and reviewed: it stays pending without a publication date until an administrator approves it, and the registration number the form asks is kept. Created by the change organisation-registration-through-the-portal (archived 2026-10-30); holds REQ-ORR-001 and REQ-ORR-002.
+How an organisation that registers itself through the public portal (board SkPubliek, "Uw organisatie aanmelden") is stored and reviewed: it stays pending without a publication date until an administrator approves it, and the registration number the form asks is kept. Created by the change organisation-registration-through-the-portal (archived 2026-10-30); holds REQ-ORR-001 and REQ-ORR-002. REQ-ORR-003 keeps a registration from binding to an organisation that is not its own.
## Requirements
@@ -34,3 +34,29 @@ The organisation schema SHALL hold `website`, `registrationNumber` (a KvK number
- **GIVEN** an active organisation registered with a contact e-mail address
- **WHEN** an anonymous visitor reads it
- **THEN** the contact name and e-mail address are not in the answer
+
+### Requirement: REQ-ORR-003 A registration never binds to an organisation that is not its own
+
+An organisation object SHALL only be linked to the OpenRegister organisation with its uuid when that organisation was made from it, which shows in it being at least as new as the object, or when an administrator linked the two. Otherwise the sync SHALL leave the existing organisation, its members and its active state untouched, process none of the object's contact persons, log a warning and count the object under `organisationIdConflicts`. An anonymous create that carries the uuid of an existing OpenRegister organisation SHALL be refused.
+
+#### Scenario: A crafted registration names an existing organisation's uuid
+@e2e exclude A request crafted outside the browser; tests/Unit/EventListener/AnonymousRegistrationListenerTest.php asserts the refusal.
+
+- **GIVEN** an OpenRegister organisation and a request without a session
+- **WHEN** the request posts an organisation with that organisation's uuid as its `id`
+- **THEN** the create is refused with a message that names no organisation
+
+#### Scenario: The sync meets an object with the uuid of an older organisation
+@e2e exclude The sync's decision on stored dates; tests/Unit/Service/OrganizationSyncEntityGoldenTest.php asserts no writes, no contact processing and the count.
+
+- **GIVEN** a stored organisation object created after the OpenRegister organisation with its uuid, with no link recorded
+- **WHEN** the event path or the scheduled sync processes that object
+- **THEN** nothing is written and its contact persons are not processed
+- **AND** the object is counted under `organisationIdConflicts`
+
+#### Scenario: An administrator confirms a refused pair
+@e2e exclude An occ command; tests/Unit/Service/OrganizationSyncEntityGoldenTest.php asserts the link and what it records.
+
+- **GIVEN** an organisation object the sync refused, and that it does belong with the organisation holding its uuid
+- **WHEN** an administrator runs `occ stackiq:organisation:link` with that uuid twice
+- **THEN** the link is recorded and the sync treats the two as linked
diff --git a/tests/Unit/Command/OrganisationLinkCommandTest.php b/tests/Unit/Command/OrganisationLinkCommandTest.php
index 972e50cd4..27222a1e3 100644
--- a/tests/Unit/Command/OrganisationLinkCommandTest.php
+++ b/tests/Unit/Command/OrganisationLinkCommandTest.php
@@ -46,6 +46,7 @@ public static function refusals(): array {
'no organisation' => ['no-organisation', 'No OpenRegister organisation has this uuid.'],
'organisation in use' => ['organisation-in-use', 'belongs to another organisatie'],
'default organisation' => ['default-organisation', 'default organisation, which is never linked'],
+ 'default unknown' => ['default-unknown', 'cannot say which organisation is its default'],
'failure' => [new RuntimeException('db down'), 'Could not link: db down'],
];
}//end refusals()
diff --git a/tests/Unit/EventListener/AnonymousRegistrationListenerTest.php b/tests/Unit/EventListener/AnonymousRegistrationListenerTest.php
index 4600c35eb..8f05d862b 100644
--- a/tests/Unit/EventListener/AnonymousRegistrationListenerTest.php
+++ b/tests/Unit/EventListener/AnonymousRegistrationListenerTest.php
@@ -5,9 +5,12 @@
namespace OCA\Stackiq\Tests\Unit\EventListener;
use OCA\OpenRegister\Db\ObjectEntity;
+use OCA\OpenRegister\Db\Organisation;
+use OCA\OpenRegister\Db\OrganisationMapper;
use OCA\OpenRegister\Event\ObjectCreatingEvent;
use OCA\Stackiq\EventListener\AnonymousRegistrationListener;
use OCA\Stackiq\Service\SettingsService;
+use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
@@ -44,6 +47,42 @@ public function testASignedInCreateKeepsItsValues(): void {
self::assertSame([], $event->getModifiedData());
}//end testASignedInCreateKeepsItsValues()
+ public function testAnAnonymousCreateCarryingAnOrganisationsUuidIsRefused(): void {
+ $event = $this->event(schema: '30', data: ['name' => 'Chosen'], uuid: 'held-uuid');
+
+ $this->listener(user: null, held: ['held-uuid'])->handle($event);
+
+ self::assertTrue($event->isPropagationStopped());
+ self::assertSame(['message' => 'This organisation could not be registered.'], $event->getErrors());
+ self::assertSame([], $event->getModifiedData());
+ }//end testAnAnonymousCreateCarryingAnOrganisationsUuidIsRefused()
+
+ public function testAnAnonymousCreateWithAFreeUuidGoesAhead(): void {
+ foreach (['free-uuid', null, ''] as $uuid) {
+ $event = $this->event(schema: '30', data: ['name' => 'Chosen'], uuid: $uuid);
+
+ $this->listener(user: null, held: ['held-uuid', ''])->handle($event);
+
+ self::assertFalse($event->isPropagationStopped());
+ self::assertSame('pending', $event->getModifiedData()['registrationStatus']);
+ }
+ }//end testAnAnonymousCreateWithAFreeUuidGoesAhead()
+
+ public function testOnlyAnAnonymousOrganisationCreateIsRefused(): void {
+ $cases = [
+ [$this->createMock(IUser::class), false, '30'],
+ [null, true, '30'],
+ [null, false, '31'],
+ ];
+ foreach ($cases as [$user, $commandLine, $schema]) {
+ $event = $this->event(schema: $schema, data: ['name' => 'Chosen'], uuid: 'held-uuid');
+
+ $this->listener(user: $user, commandLine: $commandLine, held: ['held-uuid'])->handle($event);
+
+ self::assertFalse($event->isPropagationStopped());
+ }
+ }//end testOnlyAnAnonymousOrganisationCreateIsRefused()
+
public function testAnotherSchemaIsLeftAlone(): void {
$event = $this->event(schema: '31', data: ['name' => 'A module']);
@@ -55,10 +94,11 @@ public function testAnotherSchemaIsLeftAlone(): void {
/**
* @param array $data The object data.
*/
- private function event(string $schema, array $data): ObjectCreatingEvent {
+ private function event(string $schema, array $data, ?string $uuid = null): ObjectCreatingEvent {
$entity = $this->createMock(ObjectEntity::class);
$entity->method('getSchema')->willReturn($schema);
$entity->method('getObject')->willReturn($data);
+ $entity->method('getUuid')->willReturn($uuid);
return new ObjectCreatingEvent($entity);
}//end event()
@@ -71,12 +111,26 @@ public function testACommandLineCreateKeepsItsValues(): void {
self::assertSame([], $event->getModifiedData());
}//end testACommandLineCreateKeepsItsValues()
- private function listener(?IUser $user, bool $commandLine = false): AnonymousRegistrationListener {
+ /**
+ * @param list $held The uuids OpenRegister organisations hold.
+ */
+ private function listener(?IUser $user, bool $commandLine = false, array $held = []): AnonymousRegistrationListener {
$session = $this->createMock(IUserSession::class);
$session->method('getUser')->willReturn($user);
$settings = $this->createMock(SettingsService::class);
$settings->method('getVoorzieningenConfig')->willReturn(['organisatie_schema' => '30']);
- return new AnonymousRegistrationListener($session, $settings, $commandLine);
+ $organisations = $this->createMock(OrganisationMapper::class);
+ $organisations->method('findByUuid')->willReturnCallback(
+ function (string $uuid) use ($held) {
+ if (in_array($uuid, $held, true) === false) {
+ throw new DoesNotExistException('missing');
+ }
+
+ return $this->createMock(Organisation::class);
+ }
+ );
+
+ return new AnonymousRegistrationListener($session, $settings, $organisations, $commandLine);
}//end listener()
}//end class
diff --git a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php
index c9aeb837a..3894aba41 100644
--- a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php
+++ b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php
@@ -98,6 +98,13 @@ private function entity(array $object, string $uuid): ObjectEntityInterface {
*/
private ?string $organisation = 'org-of-entity';
+ /**
+ * When the object was created.
+ *
+ * @var \DateTimeInterface|null
+ */
+ public ?\DateTimeInterface $createdAt = null;
+
/**
* Build the double.
*
@@ -106,6 +113,14 @@ private function entity(array $object, string $uuid): ObjectEntityInterface {
* @param array $trace The shared trace.
*/
public function __construct(private array $object, private string $uuid, private array &$trace) {
+ $this->createdAt = new \DateTimeImmutable('2020-06-01 10:00:00 UTC');
+ }
+
+ /**
+ * @return \DateTimeInterface|null
+ */
+ public function getCreated(): ?\DateTimeInterface {
+ return $this->createdAt;
}
/**
@@ -376,6 +391,7 @@ function () {
'organisationService' => $this->organisationService(),
'emailService' => $this->emailService(),
'container' => $this->container(),
+ 'config' => $this->appConfig(),
];
foreach ($properties as $name => $value) {
$reflection->getProperty($name)->setValue($service, $value);
@@ -385,6 +401,29 @@ function () {
}//end service()
+ /**
+ * An app config double whose stored values are the plan's `appConfig`.
+ *
+ * @return \OCP\IAppConfig
+ */
+ private function appConfig(): \OCP\IAppConfig {
+ $config = $this->createMock(originalClassName: \OCP\IAppConfig::class);
+ $config->method('getValueString')->willReturnCallback(
+ function (string $app, string $key, string $default='', bool $lazy=false) {
+ $this->trace[] = ['config.get', $app, $key, $lazy];
+ return ($this->plan['appConfig'][$app . '/' . $key] ?? $default);
+ }
+ );
+ $config->method('setValueString')->willReturnCallback(
+ function (string $app, string $key, string $value, bool $lazy=false) {
+ $this->trace[] = ['config.set', $app, $key, $lazy];
+ return true;
+ }
+ );
+ return $config;
+
+ }//end appConfig()
+
/**
* A container that answers OpenRegister's OrganisationService, whose default
* organisation is the plan's `default` (o-default when not set).
@@ -522,12 +561,27 @@ function (array $data) use ($method) {
public function orgEntity(string $uuid, bool $active): Organisation {
$trace = &$this->trace;
return new class($uuid, $active, $trace) extends Organisation {
+ /**
+ * When the organisation was created.
+ *
+ * @var \DateTimeInterface|null
+ */
+ public ?\DateTimeInterface $createdAt = null;
+
/**
* @param string $entityUuid The uuid.
* @param bool $isActive Whether it is active.
* @param array $trace The shared trace.
*/
public function __construct(private string $entityUuid, private bool $isActive, private array &$trace) {
+ $this->createdAt = new \DateTimeImmutable('2020-06-01 10:00:05 UTC');
+ }
+
+ /**
+ * @return \DateTimeInterface|null
+ */
+ public function getCreated(): ?\DateTimeInterface {
+ return $this->createdAt;
}
/**
@@ -716,6 +770,249 @@ public function testAFallbackNameWithAFreeSlugIsCreated(): void {
self::assertArrayNotHasKey('slugConflicts', $stats);
}//end testAFallbackNameWithAFreeSlugIsCreated()
+ /**
+ * An organisation older than the organisatie that carries its uuid was not made
+ * from that organisatie: nothing is bound, whatever status the organisatie claims.
+ *
+ * @return void
+ */
+ public function testAnOrganisatieDoesNotBindAnOlderOrganisationWithItsUuid(): void {
+ foreach (['Active', 'Inactive', 'Draft'] as $status) {
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0];
+ $entity = $this->ensureWithDates(status: $status, stats: $stats, dates: ['2020-06-01 10:00:01 UTC', '2020-06-01 10:00:00 UTC']);
+
+ self::assertNull($entity, $status);
+ self::assertSame([], $this->writes(), $status);
+ self::assertSame(['o-1'], $stats['organisationIdConflicts'] ?? null, $status);
+ self::assertSame(0, $stats['entitiesUpdated'], $status);
+ self::assertArrayNotHasKey('slugConflicts', $stats, $status);
+ self::assertSame([], array_values(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'mail.'))), $status);
+ $warnings = array_values(array_filter($this->trace, fn (array $call) => $call[0] === 'log.warning'));
+ self::assertCount(1, $warnings, $status);
+ self::assertSame('o-1', $warnings[0][2]['organisatieId']);
+ }
+ }//end testAnOrganisatieDoesNotBindAnOlderOrganisationWithItsUuid()
+
+ /**
+ * The organisation the sync made from this organisatie is at least as new, even
+ * when the object in hand still carries the sub-second part of its creation.
+ *
+ * @return void
+ */
+ public function testAnOrganisationMadeFromTheOrganisatieStaysBound(): void {
+ foreach ([['2020-06-01 10:00:00.700 UTC', '2020-06-01 10:00:00 UTC'], ['2020-06-01 10:00:00 UTC', '2020-06-01 10:00:09 UTC']] as $dates) {
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0];
+ $entity = $this->ensureWithDates(status: 'Inactive', stats: $stats, dates: $dates);
+
+ self::assertNotNull($entity, $dates[0]);
+ self::assertContains(['mapper.save', 'o-1', false], $this->trace, $dates[0]);
+ self::assertArrayNotHasKey('organisationIdConflicts', $stats, $dates[0]);
+ }
+ }//end testAnOrganisationMadeFromTheOrganisatieStaysBound()
+
+ /**
+ * Without both creation dates nothing shows the two belong together, so nothing is bound.
+ *
+ * @return void
+ */
+ public function testAnUnknownCreationDateDoesNotBind(): void {
+ foreach ([[null, '2020-06-01 10:00:05 UTC'], ['2020-06-01 10:00:00 UTC', null], [null, null]] as $dates) {
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0];
+ $entity = $this->ensureWithDates(status: 'Active', stats: $stats, dates: $dates);
+
+ self::assertNull($entity);
+ self::assertSame([], $this->writes());
+ self::assertSame(['o-1'], $stats['organisationIdConflicts'] ?? null);
+ }
+ }//end testAnUnknownCreationDateDoesNotBind()
+
+ /**
+ * An organisation an administrator linked may be older than the organisatie.
+ *
+ * @return void
+ */
+ public function testAnAdministratorsLinkKeepsAnOlderOrganisationBound(): void {
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0];
+ $entity = $this->ensureWithDates(
+ status: 'Inactive',
+ stats: $stats,
+ dates: ['2020-06-01 10:00:01 UTC', '2020-06-01 10:00:00 UTC'],
+ appConfig: ['stackiq/linked_organisation_o-1' => '2026-11-03 10:00:00']
+ );
+
+ self::assertNotNull($entity);
+ self::assertContains(['config.get', 'stackiq', 'linked_organisation_o-1', true], $this->trace);
+ self::assertContains(['mapper.save', 'o-1', false], $this->trace);
+ }//end testAnAdministratorsLinkKeepsAnOlderOrganisationBound()
+
+ /**
+ * Another organisatie's link says nothing about this one.
+ *
+ * @return void
+ */
+ public function testAnotherOrganisatiesLinkDoesNotBind(): void {
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0];
+ $entity = $this->ensureWithDates(
+ status: 'Active',
+ stats: $stats,
+ dates: ['2020-06-01 10:00:01 UTC', '2020-06-01 10:00:00 UTC'],
+ appConfig: ['stackiq/linked_organisation_o-2' => '2026-11-03 10:00:00', 'other/linked_organisation_o-1' => '2026-11-03 10:00:00']
+ );
+
+ self::assertNull($entity);
+ }//end testAnotherOrganisatiesLinkDoesNotBind()
+
+ /**
+ * A refused organisatie's contact persons, nested or listed, reach neither a
+ * user account nor the organisation's members, on the event path.
+ *
+ * @return void
+ */
+ public function testARefusedOrganisatieProcessesNoContactPersons(): void {
+ $object = $this->entity(
+ object: ['name' => 'Chosen', 'status' => 'Active', 'contactpersonen' => [['email' => 'someone@example.org'], 'c-listed']],
+ uuid: 'o-1'
+ );
+ $older = $this->orgEntity(uuid: 'o-1', active: true);
+ $older->createdAt = new \DateTimeImmutable('2020-01-01 00:00:00 UTC');
+ $this->trace = [];
+ $this->plan = ['find' => [null], 'save' => [], 'org' => [$older], 'slug' => []];
+
+ $stats = $this->service()->processSpecificOrganization($object);
+
+ self::assertSame(['o-1'], $stats['organisationIdConflicts'] ?? null);
+ self::assertSame(0, $stats['contactPersonsProcessed']);
+ self::assertSame(0, $stats['organizationsProcessed']);
+ self::assertSame([], $this->writes());
+ self::assertSame([], array_values(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'handler.'))));
+ self::assertSame(['objects.find'], array_values(array_unique(array_column(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'objects.')), 0))));
+ }//end testARefusedOrganisatieProcessesNoContactPersons()
+
+ /**
+ * The scheduled sync goes no further than a refused organisatie either.
+ *
+ * @return void
+ */
+ public function testTheScheduledSyncProcessesNoContactPersonsOfARefusedOrganisatie(): void {
+ $older = $this->orgEntity(uuid: 'o-1', active: true);
+ $older->createdAt = new \DateTimeImmutable('2020-01-01 00:00:00 UTC');
+ $this->trace = [];
+ $this->plan = ['find' => [null], 'save' => [], 'org' => [$older], 'slug' => []];
+ $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0, 'usersUpdated' => 0];
+ $method = new \ReflectionMethod(OrganizationSyncService::class, 'processOrganisationObject');
+
+ $method->invokeArgs($this->service(), [$this->entity(object: ['name' => 'Chosen', 'status' => 'Active'], uuid: 'o-1'), 'reg', 'contact-schema', &$stats]);
+
+ self::assertSame(['o-1'], $stats['organisationIdConflicts'] ?? null);
+ self::assertSame([], $this->writes());
+ self::assertSame(['objects.find'], array_values(array_unique(array_column(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'objects.')), 0))));
+ }//end testTheScheduledSyncProcessesNoContactPersonsOfARefusedOrganisatie()
+
+ /**
+ * Run the ensure with an organisation that has the organisatie's uuid.
+ *
+ * @param string $status The organisatie's status.
+ * @param array $stats The stats, written by reference.
+ * @param array $dates When the object and the organisation were created (null: unknown).
+ * @param array $appConfig The stored app config, keyed app/key.
+ *
+ * @return object|null What the ensure returned.
+ */
+ private function ensureWithDates(string $status, array &$stats, array $dates, array $appConfig=[]): ?object {
+ $object = $this->entity(object: ['name' => 'Chosen', 'status' => $status], uuid: 'o-1');
+ $object->createdAt = $dates[0] === null ? null : new \DateTimeImmutable($dates[0]);
+ $entity = $this->orgEntity(uuid: 'o-1', active: true);
+ $entity->createdAt = $dates[1] === null ? null : new \DateTimeImmutable($dates[1]);
+ $this->trace = [];
+ $this->plan = ['find' => [null], 'save' => [], 'org' => [$entity], 'slug' => [], 'appConfig' => $appConfig];
+
+ return $this->service()->ensureOrganisationEntityPublic($object, $stats, true);
+ }//end ensureWithDates()
+
+ /**
+ * The writes in the trace.
+ *
+ * @return array
+ */
+ private function writes(): array {
+ return array_values(array_filter($this->trace, fn (array $call) => in_array($call[0], ['entity.setUuid', 'entity.setActive', 'mapper.save', 'organisations.create', 'objects.saveObject', 'entity.setOwner', 'entity.setOrganisation', 'entity.setObject'], true)));
+ }//end writes()
+
+ /**
+ * A link names what it moved, reads the organisatie and its would-be holder past
+ * access control and organisation scope, and is remembered before anything moves.
+ *
+ * @return void
+ */
+ public function testALinkIsAuditedAndRemembered(): void {
+ $this->trace = [];
+ $this->plan = [
+ 'find' => [$this->entity(object: ['name' => 'Gemeente A'], uuid: 'o-1'), null],
+ 'save' => [],
+ 'org' => [new \OCP\AppFramework\Db\DoesNotExistException('missing'), $this->orgEntity('o-old', true), $this->orgEntity('o-old', true)],
+ 'slug' => [],
+ ];
+
+ self::assertSame('linked', $this->service()->linkOrganisationEntity('o-1', 'o-old'));
+
+ $finds = array_values(array_filter($this->trace, fn (array $call) => $call[0] === 'objects.find'));
+ self::assertSame(['id' => 'o-1', 'register' => 'reg-v', 'schema' => 'org-schema', '_rbac' => false, '_multitenancy' => false], $finds[0][1]);
+ self::assertSame(['id' => 'o-old', 'register' => 'reg-v', 'schema' => 'org-schema', '_rbac' => false, '_multitenancy' => false], $finds[1][1]);
+
+ $logged = array_values(array_filter($this->trace, fn (array $call) => $call[0] === 'log.info' && str_contains($call[1], 'an administrator linked')));
+ self::assertCount(1, $logged);
+ self::assertSame(['app' => 'stackiq', 'organisatieId' => 'o-1', 'previousOrganisationUuid' => 'o-old'], $logged[0][2]);
+
+ $calls = array_column($this->trace, 0);
+ self::assertContains(['config.set', 'stackiq', 'linked_organisation_o-1', true], $this->trace);
+ self::assertLessThan(array_search('entity.setUuid', $calls, true), array_search('config.set', $calls, true));
+ }//end testALinkIsAuditedAndRemembered()
+
+ /**
+ * An administrator can confirm an organisatie the sync refused because it
+ * carries the uuid of an older organisation; the organisation keeps that uuid.
+ *
+ * @return void
+ */
+ public function testAnAdministratorCanConfirmARefusedPair(): void {
+ $object = $this->entity(object: ['name' => 'Gemeente A'], uuid: 'o-1');
+ $older = $this->orgEntity(uuid: 'o-1', active: true);
+ $older->createdAt = new \DateTimeImmutable('2020-01-01 00:00:00 UTC');
+ $this->trace = [];
+ $this->plan = ['find' => [$object, $object], 'save' => [], 'org' => [$older, $older, $older], 'slug' => []];
+
+ self::assertSame('linked', $this->service()->linkOrganisationEntity('o-1', 'o-1'));
+ self::assertContains(['config.set', 'stackiq', 'linked_organisation_o-1', true], $this->trace);
+ self::assertContains(['entity.setUuid', 'o-1', 'o-1'], $this->trace);
+ self::assertContains('objects.saveObject', array_column($this->trace, 0));
+ }//end testAnAdministratorCanConfirmARefusedPair()
+
+ /**
+ * A pair that already holds is not linked again, and the default organisation is never confirmed.
+ *
+ * @return void
+ */
+ public function testAPairThatHoldsOrTheDefaultOrganisationIsNotConfirmed(): void {
+ $object = $this->entity(object: ['name' => 'Gemeente A'], uuid: 'o-1');
+ $this->trace = [];
+ $this->plan = ['find' => [$object], 'save' => [], 'org' => [$this->orgEntity('o-1', true)], 'slug' => []];
+ self::assertSame('already-linked', $this->service()->linkOrganisationEntity('o-1', 'o-1'));
+ self::assertSame([], $this->writes());
+
+ $older = $this->orgEntity(uuid: 'o-1', active: true);
+ $older->createdAt = new \DateTimeImmutable('2020-01-01 00:00:00 UTC');
+ $this->trace = [];
+ $this->plan = ['find' => [$object], 'save' => [], 'org' => [$older, $older], 'slug' => [], 'default' => 'o-1'];
+ self::assertSame('default-organisation', $this->service()->linkOrganisationEntity('o-1', 'o-1'));
+ self::assertSame([], $this->writes());
+ self::assertNotContains('config.set', array_column($this->trace, 0));
+
+ $this->trace = [];
+ $this->plan = ['find' => [$object], 'save' => [], 'org' => [$older], 'slug' => []];
+ self::assertSame('already-linked', $this->service()->linkOrganisationEntity('o-1', 'o-old'));
+ self::assertSame([], $this->writes());
+ }//end testAPairThatHoldsOrTheDefaultOrganisationIsNotConfirmed()
+
/**
* Checking a link answers what the link would, and writes nothing.
*
@@ -756,7 +1053,8 @@ public static function linkCases(): array {
'no organisation' => ['no-organisation', fn ($t) => ['find' => [$object($t, 'o-1')], 'org' => [$missing(), $missing()]], []],
'organisation in use' => ['organisation-in-use', fn ($t) => ['find' => [$object($t, 'o-1'), $object($t, 'o-old')], 'org' => [$missing(), $t->orgEntity('o-old', true)]], []],
'default organisation' => ['default-organisation', fn ($t) => ['find' => [$object($t, 'o-1')], 'org' => [$missing(), $t->orgEntity('o-old', true)], 'default' => 'o-old'], []],
- 'no default set' => ['linked', fn ($t) => ['find' => [$object($t, 'o-1'), null], 'org' => [$missing(), $t->orgEntity('o-old', true), $t->orgEntity('o-old', true)], 'default' => null], ['entity.setUuid', 'mapper.save', 'objects.saveObject']],
+ 'no default set' => ['default-unknown', fn ($t) => ['find' => [$object($t, 'o-1')], 'org' => [$missing(), $t->orgEntity('o-old', true)], 'default' => null], []],
+ 'empty default set' => ['default-unknown', fn ($t) => ['find' => [$object($t, 'o-1')], 'org' => [$missing(), $t->orgEntity('o-old', true)], 'default' => ''], []],
];
}//end linkCases()