fix(merge): organisation merge re-points nothing — probe a magic accessor with property_exists - #491
Merged
Conversation
…ssor 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
…r 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.
Contributor
Quality Report — ConductionNL/softwarecatalog @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 128/128 | |||
| npm | ✅ | ✅ 718/718 | |||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-11 23:53 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/softwarecatalog @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 128/128 | |||
| npm | ✅ | ✅ 718/718 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-12 00:07 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #490.
The defect
MergeOrganisatieService::repointBySelfOrganisation()decided whether an object was owned by the source organisation withmethod_exists($entity, 'getOrganisation').OpenRegister's
ObjectEntitydeclares that accessor only as an@methoddocblock tag overprotected ?string $organisation(ObjectEntity.php:89/:260), so it is served byOCP\AppFramework\Db\Entity::__call(). The probe is therefore always false,$owningOrganisationstayednull, and the following linecontinued every object.Consequence:
contractandcompliancywere never re-pointed, whiletombstoneSource()still retired the source organisation. A merge reported success having moved nothing, leaving live objects owned by an organisation that no longer exists. The dry-run/execute parity check could not catch it — both arms were equally broken, so they agreed.Why the suite was green
tests/Stubs/Db/ObjectEntity.phpdeclaredgetOrganisation()concretely, somethod_existswas true in the suite and false in production. The double inverted the exact predicate under test.That is now fixed at the source: the stub extends the real
OCP\AppFramework\Db\Entity, declaresorganisationas a property rather than an accessor, and carries a warning explaining what adding an accessor there costs. The merge suite'sentity()helper builds a faithful concrete subclass, andtestTheEntityDoubleMatchesTheRealObjectEntityAccessorShapeasserts that premise so the fixture cannot silently drift back.The instrument
property_exists()— which is whatEntity::getter()itself decides on.is_callable()is explicitly not used: it is true for any name on a__callclass, so a probe swap would make the branch unconditionally true and move the failure into a runtimeBadFunctionCallException. The accessor call is wrapped and its result type-checked in the same edit, andmethod_existsis kept as a second arm for an entity that genuinely declares the accessor.Reading
@self.organisationoffjsonSerialize()was considered and rejected:getObjectArray()typesorganisationasarray|string|null, so an expanded organisation would silently fail the UUID comparison in the caller.Also fixed — same probe, same family
ReviewService::entityUuid()andIntakeService::entityUuid()(byte-identical copies) probedmethod_exists($entity, 'getUuid').saveObject()returns an object, so theis_array()fallback below could never rescue it: both returnednullfor every real save, puttinguuid: nullin the submit response and in the audit log.The other seven
getUuidprobes in this repo are dead code with a working fallback —ObjectEntity::getObject()always injects the uuid underid, so their$data['id']arm already holds. Read, classified by what the false branch does, and deliberately left alone.Evidence
Both revert predictions were written down before reverting, and reverted with the editor rather than a script:
entityUuidprobes was predicted to turn the two new tests red and nothing else. Observed: exactly that, over the full 667-test suite.Full unit suite passes at 667 tests / 2332 assertions. phpcs, phpmd, psalm and phpstan all exit clean.
Known remaining debt, not hidden
The stub still declares
getUuid()concretely because eight other test files configure it on a mock. A double routed throughsaveObject()'s: ObjectEntityreturn type therefore cannot express the magicgetUuidshape, so the two newentityUuidtests exercise the private method directly against a faithfulEntitysubclass. Reshaping that declaration across all eight files is a separate change.Docblock correction
The class docblock credited the
@self.organisationwrite path toSaveObject::applyCallerSuppliedFields(). No such method exists anywhere in OpenRegister — grepped across the whole tree with a positive control. The real acceptance path isSaveObject::setSelfMetadata(), which honours a caller-supplied@self.organisationfor an admin or a verified member of the target organisation; a merge is admin-triggered, so the admin arm applies.