Skip to content

Commit d401ab7

Browse files
authored
fix(merge): organisation merge re-points nothing — probe a magic accessor with property_exists (#491)
* fix(merge): organisation merge re-points nothing — probe a magic accessor with property_exists MergeOrganisatieService::repointBySelfOrganisation() decided whether an object was owned by the source organisation with method_exists($entity, 'getOrganisation'). OpenRegister's ObjectEntity declares that accessor only as an @method docblock tag over protected ?string $organisation, so it is served by OCP\AppFramework\Db\Entity::__call() and the probe is always false. The next line skipped every object, so contract and compliancy were never re-pointed while tombstoneSource() still retired the source organisation — leaving live objects owned by an organisation that no longer exists. Dry-run and execute agreed only because both arms were equally broken. The instrument is property_exists(), which is what Entity::getter() itself decides on. is_callable() is not a membership test on a __call class — it is true for every name, so a probe swap would make the branch unconditionally true and move the failure into a runtime BadFunctionCallException. The accessor call is wrapped and the result type-checked in the same edit. The same probe in ReviewService::entityUuid() and IntakeService::entityUuid() made both return null for every real save, because saveObject() returns an object and the is_array() fallback cannot rescue it — so submit() answered uuid: null to the client and wrote uuid: null to the audit log. Why the suite was green: tests/Stubs/Db/ObjectEntity declared getOrganisation() concretely, which inverted the exact predicate under test. The merge suite now builds a faithful double — a concrete subclass of the stub, which extends the real Entity, with organisation as a property reached through __call — and one test asserts that premise so the fixture cannot drift back. The stub no longer declares getOrganisation()/setOrganisation() and carries a warning about what adding an accessor there costs. Reverting only the merge probe turns 6 tests red; reverting only the two entityUuid probes turns 2 red. Both predictions were written before the revert and matched exactly. 667 unit tests pass; phpcs, phpmd, psalm and phpstan clean. Also corrects a stale class docblock: it credited the @self.organisation write path to SaveObject::applyCallerSuppliedFields(), a method that exists nowhere in OpenRegister. The real acceptance path is SaveObject::setSelfMetadata(). Closes #490 * fix(tests): keep the ObjectEntity stub free-standing so it loads under both bootstraps The previous commit made the stub extend OCP\AppFramework\Db\Entity. That is fine under tests/bootstrap-unit.php, which registers an OCP autoloader, but tests/bootstrap.php require_once's every file in tests/Stubs/ BEFORE Nextcloud's lib/base.php — deliberately, so the stub wins over the real OpenRegister class during mock generation. At that point no OCP class is resolvable, so the whole suite died in the bootstrap with Error in bootstrap script: Class "OCP\AppFramework\Db\Entity" not found on both PHPUnit cells. The local unit run could not see it because phpunit-unit.xml uses the other bootstrap. The stub now mirrors Entity's __call/getter/setter triple instead of inheriting it, so it has no load-time dependency at all. The semantics that the fix turns on are reproduced exactly: get*/set* resolve through property_exists(), anything else raises BadFunctionCallException. Verified by replaying the exact failing bootstrap step — vendor/autoload.php plus the tests/Stubs glob, with no Nextcloud and no OCP autoloader. The committed version fatals there; this version loads clean. The revert prediction is unchanged: reverting the merge probe still turns exactly the same 6 tests red.
1 parent ea4dc7e commit d401ab7

7 files changed

Lines changed: 547 additions & 27 deletions

File tree

lib/Service/IntakeService.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,15 +269,34 @@ private function resolveTarget(): ?array
269269
/**
270270
* The uuid of a saved entity (handles entity or array result shapes).
271271
*
272+
* `ObjectService::saveObject()` returns an `ObjectEntity`, whose
273+
* `getUuid()` is an `@method` docblock served by `Entity::__call()` over
274+
* `protected ?string $uuid`. A bare `method_exists()` probe is therefore
275+
* FALSE, and because an object is not an array the array arm below cannot
276+
* rescue it — so this method used to return `null` for EVERY real save,
277+
* putting `uuid: null` in the submit response and the audit log
278+
* (softwarecatalog#490). `property_exists()` is the instrument
279+
* `Entity::getter()` itself decides on; `method_exists()` is kept as the
280+
* second arm for genuinely concrete accessors, and the call is wrapped
281+
* because neither probe guarantees the other object's shape.
282+
*
272283
* @param mixed $entity The saveObject result.
273284
*
274285
* @return string|null The uuid, or null.
275286
*/
276287
private function entityUuid(mixed $entity): ?string
277288
{
278-
if (is_object($entity) === true && method_exists($entity, 'getUuid') === true) {
279-
$uuid = $entity->getUuid();
280-
if (is_string($uuid) === true) {
289+
if (is_object($entity) === true
290+
&& (property_exists($entity, 'uuid') === true || method_exists($entity, 'getUuid') === true)
291+
) {
292+
try {
293+
$uuid = $entity->getUuid();
294+
} catch (\Throwable $e) {
295+
$this->logger->warning('IntakeService: could not read uuid from saved entity', ['exception' => $e->getMessage()]);
296+
return null;
297+
}
298+
299+
if (is_string($uuid) === true && $uuid !== '') {
281300
return $uuid;
282301
}
283302

lib/Service/MergeOrganisatieService.php

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@
3535
* field (no `$ref: organisatie` property), so ownership is carried by
3636
* OpenRegister's system-level `@self.organisation` (the same mechanism
3737
* design.md documents explicitly for compliancy). Re-pointed via
38-
* `@self.organisation` in the save payload, matching
39-
* `SaveObject::applyCallerSuppliedFields()`'s admin-gated
40-
* `@self.organisation` acceptance path.
38+
* `@self.organisation` in the save payload, matching OpenRegister's
39+
* `SaveObject::setSelfMetadata()` acceptance path, which honours a
40+
* caller-supplied `@self.organisation` when the caller is an admin or a
41+
* verified member of the target organisation; a merge is admin-triggered, so
42+
* the admin arm applies. (An earlier revision of this docblock named
43+
* `SaveObject::applyCallerSuppliedFields()`. No such method exists anywhere
44+
* in OpenRegister — grepped across the whole tree with a positive control.)
4145
* - compliancy: `@self.organisation` (system-level owning organisation).
4246
*
4347
* @category Service
@@ -446,10 +450,7 @@ private function repointBySelfOrganisation(string $objectType, string $source, s
446450
$count = 0;
447451

448452
foreach ($entities as $entity) {
449-
$owningOrganisation = null;
450-
if (method_exists($entity, 'getOrganisation') === true) {
451-
$owningOrganisation = $entity->getOrganisation();
452-
}
453+
$owningOrganisation = $this->readOwningOrganisation(entity: $entity);
453454

454455
if ($owningOrganisation !== $source) {
455456
continue;
@@ -467,6 +468,65 @@ private function repointBySelfOrganisation(string $objectType, string $source, s
467468
return $count;
468469
}//end repointBySelfOrganisation()
469470

471+
/**
472+
* Read an OpenRegister object's system-level owning organisation
473+
* (`@self.organisation`).
474+
*
475+
* `ObjectEntity` declares `getOrganisation()` ONLY as an `@method` docblock
476+
* tag over `protected ?string $organisation`, so the accessor is reached
477+
* through `OCP\AppFramework\Db\Entity::__call()`. Two probes are therefore
478+
* wrong here, and both fail silently:
479+
*
480+
* - `method_exists()` is **false** for every such accessor. That was
481+
* softwarecatalog#490: the caller's re-point branch never ran, so a merge
482+
* re-pointed nothing for `contract`/`compliancy` while still tombstoning
483+
* the source organisation.
484+
* - `is_callable()` is **true** for ANY name on a class with `__call()`, so
485+
* swapping the probe would make the branch unconditionally true and move
486+
* the failure into a runtime `BadFunctionCallException`.
487+
*
488+
* `Entity::getter()` itself decides on `property_exists()`, so that is the
489+
* primary instrument below; `method_exists()` is kept as a second arm for
490+
* an entity that genuinely declares the accessor. The call is still
491+
* wrapped, because `$entity` comes from `ObjectService::findAll()` and is
492+
* not type-guaranteed to be an `Entity` subclass.
493+
*
494+
* Deliberately NOT read from `jsonSerialize()`: `ObjectEntity::getObjectArray()`
495+
* types `organisation` as `array|string|null`, so an expanded organisation
496+
* would silently fail the UUID comparison in the caller. The property holds
497+
* the raw `?string`.
498+
*
499+
* @param object $entity The OpenRegister ObjectEntity to read.
500+
*
501+
* @return string|null The owning organisation UUID, or null when the entity carries none.
502+
*
503+
* @spec openspec/specs/organisation-merge/spec.md#requirement-execute-must-re-point-every-relation-type-while-preserving-every-unrelated-field-on-each-object
504+
*/
505+
private function readOwningOrganisation(object $entity): ?string
506+
{
507+
if (property_exists($entity, 'organisation') === false
508+
&& method_exists($entity, 'getOrganisation') === false
509+
) {
510+
return null;
511+
}
512+
513+
try {
514+
$owningOrganisation = $entity->getOrganisation();
515+
} catch (\Throwable $e) {
516+
$this->logger->warning(
517+
'MergeOrganisatieService: could not read @self.organisation from object entity',
518+
['exception' => $e->getMessage(), 'entity' => $entity::class]
519+
);
520+
return null;
521+
}
522+
523+
if (is_string($owningOrganisation) === false) {
524+
return null;
525+
}
526+
527+
return $owningOrganisation;
528+
}//end readOwningOrganisation()
529+
470530
/**
471531
* Save the full existing payload (only the organisation-reference field(s)
472532
* mutated) back via OpenRegister's `ObjectService::saveObject()` —

lib/Service/ReviewService.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,15 +362,34 @@ private function resolveTarget(): ?array
362362
/**
363363
* The uuid of a saved entity (handles entity or array result shapes).
364364
*
365+
* `ObjectService::saveObject()` returns an `ObjectEntity`, whose
366+
* `getUuid()` is an `@method` docblock served by `Entity::__call()` over
367+
* `protected ?string $uuid`. A bare `method_exists()` probe is therefore
368+
* FALSE, and because an object is not an array the array arm below cannot
369+
* rescue it — so this method used to return `null` for EVERY real save,
370+
* putting `uuid: null` in the submit response and the audit log
371+
* (softwarecatalog#490). `property_exists()` is the instrument
372+
* `Entity::getter()` itself decides on; `method_exists()` is kept as the
373+
* second arm for genuinely concrete accessors, and the call is wrapped
374+
* because neither probe guarantees the other object's shape.
375+
*
365376
* @param mixed $entity The saveObject result.
366377
*
367378
* @return string|null The uuid, or null.
368379
*/
369380
private function entityUuid(mixed $entity): ?string
370381
{
371-
if (is_object($entity) === true && method_exists($entity, 'getUuid') === true) {
372-
$uuid = $entity->getUuid();
373-
if (is_string($uuid) === true) {
382+
if (is_object($entity) === true
383+
&& (property_exists($entity, 'uuid') === true || method_exists($entity, 'getUuid') === true)
384+
) {
385+
try {
386+
$uuid = $entity->getUuid();
387+
} catch (\Throwable $e) {
388+
$this->logger->warning('ReviewService: could not read uuid from saved entity', ['exception' => $e->getMessage()]);
389+
return null;
390+
}
391+
392+
if (is_string($uuid) === true && $uuid !== '') {
374393
return $uuid;
375394
}
376395

tests/Stubs/Db/ObjectEntity.php

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,40 @@
88
* stub declares the getters/setters the unit tests stub explicitly. Resolved
99
* via the `OCA\OpenRegister\ => tests/Stubs/` autoload-dev mapping.
1010
*
11+
* ⚠️ KNOWN UNFAITHFULNESS — read before adding a declaration here.
12+
* Every accessor below is magic on the REAL ObjectEntity, so declaring it here
13+
* makes `method_exists()` TRUE in the suite and FALSE in production. A test
14+
* built on this stub therefore CANNOT detect a `method_exists()` probe against
15+
* an OpenRegister entity — that is exactly how softwarecatalog#490 (the
16+
* organisation merge re-pointing nothing while still tombstoning the source)
17+
* stayed green for its entire life. `getOrganisation()`/`setOrganisation()`
18+
* were removed from this stub for that reason.
19+
*
20+
* If your subject probes for an accessor, do NOT add it here. Declare the
21+
* attribute as a `protected` PROPERTY instead (as `organisation` is below) and
22+
* build the double as a concrete subclass of this stub rather than a
23+
* `createMock()`, so `__call()` serves the accessor exactly as it does in
24+
* production. `tests/Unit/Service/MergeOrganisatieServiceTest::entity()` is
25+
* the worked example.
26+
*
27+
* A faithful double must be a SUBCLASS of this stub, not of some other base:
28+
* `ObjectService::find()` declares `?ObjectEntity`, and an incompatible return
29+
* raises a `TypeError` that `MergeOrganisatieService::findOrganisatie()`
30+
* swallows in a `catch (\Throwable)`, turning a wiring mistake into a
31+
* plausible-looking `source-not-found` blocker.
32+
*
33+
* ⚠️ The `__call`/`getter`/`setter` triple below MIRRORS
34+
* `OCP\AppFramework\Db\Entity` (`:159`, `:175`) rather than inheriting it, and
35+
* that is deliberate. `tests/bootstrap.php` `require_once`s every file in
36+
* `tests/Stubs/` BEFORE Nextcloud's `lib/base.php`, precisely so this stub wins
37+
* over the real OpenRegister class during mock generation — so at load time no
38+
* `OCP\` class is resolvable yet, and extending one makes the whole suite die
39+
* in the bootstrap with `Class "OCP\AppFramework\Db\Entity" not found`.
40+
* Keeping the stub free-standing is what lets it load under BOTH
41+
* `tests/bootstrap.php` and `tests/bootstrap-unit.php`. The semantics that
42+
* matter are reproduced exactly: `get*`/`set*` resolve through
43+
* `property_exists()`, anything else raises `BadFunctionCallException`.
44+
*
1145
* SPDX-License-Identifier: EUPL-1.2
1246
*
1347
* @category Test
@@ -18,12 +52,88 @@
1852

1953
namespace OCA\OpenRegister\Db;
2054

55+
use BadFunctionCallException;
56+
2157
/**
2258
* Stub for ObjectEntity with the surface used by SoftwareCatalog tests.
2359
*/
2460
abstract class ObjectEntity
2561
{
2662

63+
/**
64+
* The system-level owning organisation (`@self.organisation`).
65+
*
66+
* A PROPERTY, not a declared accessor — on the real ObjectEntity this is
67+
* `protected ?string $organisation` reached through `Entity::__call()`, so
68+
* `method_exists($entity, 'getOrganisation')` is FALSE and
69+
* `property_exists($entity, 'organisation')` is TRUE. Declaring it this way
70+
* is what lets a test tell the two apart. See softwarecatalog#490.
71+
*
72+
* @var string|null
73+
*/
74+
protected ?string $organisation = null;
75+
76+
/**
77+
* Magic accessor dispatch, mirroring `OCP\AppFramework\Db\Entity::__call()`.
78+
*
79+
* @param string $method The called method name.
80+
* @param array<mixed> $args The call arguments.
81+
*
82+
* @return mixed
83+
*
84+
* @throws BadFunctionCallException When the name maps to no attribute.
85+
*/
86+
public function __call(string $method, array $args)
87+
{
88+
if (str_starts_with($method, 'get') === true) {
89+
return $this->getter(lcfirst(substr($method, 3)));
90+
}
91+
92+
if (str_starts_with($method, 'set') === true) {
93+
$this->setter(lcfirst(substr($method, 3)), $args);
94+
return $this;
95+
}
96+
97+
throw new BadFunctionCallException($method.' does not exist');
98+
}//end __call()
99+
100+
/**
101+
* Generic attribute read, mirroring `Entity::getter()`.
102+
*
103+
* @param string $name The attribute name.
104+
*
105+
* @return mixed
106+
*
107+
* @throws BadFunctionCallException When no such property exists.
108+
*/
109+
protected function getter(string $name)
110+
{
111+
if (property_exists($this, $name) === false) {
112+
throw new BadFunctionCallException($name.' is not a valid attribute');
113+
}
114+
115+
return $this->$name;
116+
}//end getter()
117+
118+
/**
119+
* Generic attribute write, mirroring `Entity::setter()`.
120+
*
121+
* @param string $name The attribute name.
122+
* @param array<mixed> $args The call arguments.
123+
*
124+
* @return void
125+
*
126+
* @throws BadFunctionCallException When no such property exists.
127+
*/
128+
protected function setter(string $name, array $args): void
129+
{
130+
if (property_exists($this, $name) === false) {
131+
throw new BadFunctionCallException($name.' is not a valid attribute');
132+
}
133+
134+
$this->$name = ($args[0] ?? null);
135+
}//end setter()
136+
27137
/** @return int */
28138
abstract public function getId();
29139

@@ -39,15 +149,6 @@ abstract public function getRegister();
39149
/** @return mixed */
40150
abstract public function getSchema();
41151

42-
/** @return string|null */
43-
abstract public function getOrganisation();
44-
45-
/**
46-
* @param string|null $organisation
47-
* @return void
48-
*/
49-
abstract public function setOrganisation($organisation=null);
50-
51152
/**
52153
* @param array<string,mixed>|null $object
53154
* @return self

tests/Unit/Service/IntakeModerationTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use OCA\SoftwareCatalog\Service\IntakeService;
3636
use OCA\SoftwareCatalog\Service\ModerationService;
3737
use OCA\SoftwareCatalog\Service\SettingsService;
38+
use OCP\AppFramework\Db\Entity;
3839
use PHPUnit\Framework\TestCase;
3940
use Psr\Container\ContainerInterface;
4041
use Psr\Log\LoggerInterface;
@@ -123,6 +124,45 @@ public function testMissingRequiredFieldRejected(): void
123124
$this->assertSame([], $this->saved);
124125
}//end testMissingRequiredFieldRejected()
125126

127+
/**
128+
* `entityUuid()` must read the uuid off a saved entity whose `getUuid()`
129+
* is reached through `Entity::__call()` — which is what every real
130+
* OpenRegister `ObjectEntity` returned by `saveObject()` does.
131+
*
132+
* With the old `method_exists()` probe this returned `null` for EVERY real
133+
* save (the `is_array()` arm cannot rescue an object), so `submit()`
134+
* answered `uuid: null` to the client and wrote `['uuid' => null]` to the
135+
* audit log — softwarecatalog#490. See the twin test in ReviewServiceTest;
136+
* the two services carry byte-identical copies of this helper.
137+
*
138+
* @return void
139+
*/
140+
public function testEntityUuidReadsAMagicAccessorUuid(): void
141+
{
142+
$entity = new class extends Entity {
143+
144+
/**
145+
* The uuid — a property reached via __call, as on ObjectEntity.
146+
*
147+
* @var string|null
148+
*/
149+
protected ?string $uuid = null;
150+
};
151+
$entity->setUuid('intake-uuid-1');
152+
153+
$this->assertFalse(
154+
method_exists($entity, 'getUuid'),
155+
'the double must reach getUuid() through __call, like the real ObjectEntity'
156+
);
157+
158+
$intake = new IntakeService($this->container($this->objectService([])), $this->settings(), $this->logger());
159+
160+
$method = new \ReflectionMethod($intake, 'entityUuid');
161+
$method->setAccessible(true);
162+
163+
$this->assertSame('intake-uuid-1', $method->invoke($intake, $entity));
164+
}//end testEntityUuidReadsAMagicAccessorUuid()
165+
126166
/**
127167
* Anti-spam validation: oversized value is rejected.
128168
*

0 commit comments

Comments
 (0)