Skip to content

Commit e58efc9

Browse files
committed
fix: close the ADR-084 contract drift behind 19 PHPUnit failures and 44 static findings
PHPUnit was red in all six matrix cells with an IDENTICAL count (Tests 705, Errors 7, Failures 12, Warnings 3, Skipped 20), which looked like a class-load fatal. It was not: the suite ran to completion in every cell. The shared cause is that this app's consumption of OpenRegister drifted from the contract OpenRegister now publishes (OCA\OpenRegister\Contract\ObjectServiceInterface / ObjectEntityInterface), and both the production code and its doubles were pinned to the older surface. Measured, in-container on PHP 8.4 against the same openregister: PHPUnit 705 tests: 19 red -> 1 red -> 0 red phpstan 37 errors -> 0 psalm 4 errors -> 0 phpmd 3 findings -> 0 phpcs exit 0 before and after (warnings only, pre-existing) Six distinct defects, not one: 1. The saveObject() double omitted the contract's second parameter. ObjectServiceInterface::saveObject() is (object, extend, register, schema, uuid, ...). MergeOrganisatieServiceTest's willReturnCallback declared (object, register, schema, uuid). PHPUnit resolves the subject's NAMED arguments against the generated mock's own signature and then invokes the callback POSITIONALLY, so the capture silently recorded extend-as-register and register-as-schema. Nothing threw; every assertion that looked a save up by (schema, uuid) reported "no such save". Six failures. The callback now mirrors the contract position for position. 2. Three controller tests wired their fixture into a ContainerInterface double while the subject holds an INJECTED contract. The subject was left holding a different, unconfigured mock: reads returned empty, and the organisation guard refused a caller reading their OWN organisation. Five failures, one of them a cross-tenant test passing straight through the check it exists to prove. 3. getObjectService() asked the container for the CONCRETE class and gated on `instanceof ObjectService`. Anything that satisfies the published interface without being that exact class - i.e. every double a leaf app can build - fell to the fail-closed arm and refused an owner. Fixed in ContractApprovalService, ContractStatusService and SbomImportService: ask for the contract, narrow on the contract. 4. Two tests referenced RegisterMapper / MetadataHydrationHandler with no import, so they resolved inside the test's own namespace. Four errors. The listener's dependency on both is gone (see 6), so the imports went with it. 5. QueryLimitBoundingTest seeded only `container` and `logger` by reflection on a newInstanceWithoutConstructor() instance. Reading an uninitialised typed property is an Error, not a null, so the test died before observing the query it exists to observe. It now seeds `objectService` too. 6. UserProfileUpdatedEventListener reached past the contract into SchemaMapper, RegisterMapper and Service\Object\SaveObject\ MetadataHydrationHandler to regenerate `_name` before saving. That was redundant - ObjectService::saveObject() calls hydrateObjectMetadata() on both its create and its update path - and it is what psalm reported as two UndefinedClass errors and phpmd as a LongVariable plus an unused $registerEntity. All three dependencies removed. Production defects found on the way, each fixed at the call site: - ContactpersonenController passed `silent: true` TWICE in one saveObject() call (a merge artefact); psalm InvalidNamedArgument, phpstan duplicate. - GebruikSyncService passed `id:` where the contract's parameter is `uuid:`. The name was corrected, not dropped. - ContactpersoonService tested `findSilent(...) === null`. findSilent() declares a NON-nullable ObjectEntityInterface and lets the mapper's DoesNotExistException out, so the distinct "not found" entry was unreachable and every miss came back carrying an `error` key instead. Now caught explicitly. - ContactPersonHandler::findContactPersonByUsername() was private, had no caller, and called findAll($filters, $registerId, $schemaId) POSITIONALLY against findAll(array $config, bool $_rbac, bool $_multitenancy) - the register id would have landed in $_rbac and the search run unscoped. Deleted with the reasoning recorded in place. - OrganizationHandler had one saveObject() with no register/schema at all, leaving the write to whatever scope the service happened to carry. It now falls back to the entity's own coordinates. Six call sites pushed a payload into the entity with setObject() and read it straight back out. setObject(), setOrganisation() and getId() are implementation-only accessors reached through Entity::__call() and are not on ObjectEntityInterface; the payload is now threaded through explicitly. saveObject() is PUT-semantic, so every unchanged field is still carried forward. Two constructors dropped an unused ContainerInterface (phpstan: "never read, only written") - ADR-084 replaced the lazy lookup with the injected contract. lib/AppInfo/Application.php's hand-written factories updated to match; tests/Unit/AppInfo/CompositionRootArgumentsTest.php covers that. tests/Stubs/Db/ObjectEntity.php's header documented the OPPOSITE of the current truth. It said getOrganisation() is magic on the real entity, so declaring it here inverts method_exists(). ADR-084 changed that: the real ObjectEntity implements ObjectEntityInterface, and an interface method cannot be served by __call(), so the real class declares all six concretely. The stub mirrors it, keeps the backing `organisation` property (that is what Entity::getter() and readOwningOrganisation() key on), and the header now says so. testTheMagicEntityDoubleMatchesTheRealObjectEntity AccessorShape was asserting the pre-ADR-084 shape and is re-pointed at the current one, pinning BOTH halves so the softwarecatalog#490 data-loss path cannot come back. No named argument was removed anywhere in this change. `id:` -> `uuid:` is a NAME correction to match the published signature. E2E Tests, Hydra Gates and Quality Report are NOT addressed here and remain red; they were not diagnosed.
1 parent 5380993 commit e58efc9

25 files changed

Lines changed: 324 additions & 305 deletions

lib/AppInfo/Application.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,6 @@ function ($container) {
377377
return new GebruikSyncService(
378378
logger: $container->get('Psr\Log\LoggerInterface'),
379379
settingsService: $container->get(SettingsService::class),
380-
container: $container,
381380
objectService: $container->get(ObjectServiceInterface::class),
382381
);
383382
}
@@ -738,7 +737,6 @@ function ($container) {
738737
userManager: $container->get('OCP\IUserManager'),
739738
groupManager: $container->get('OCP\IGroupManager'),
740739
userSession: $container->get('OCP\IUserSession'),
741-
container: $container,
742740
secureRandom: $container->get('OCP\Security\ISecureRandom'),
743741
logger: $container->get('Psr\Log\LoggerInterface'),
744742
// ADR-084 added these two to the constructor; this hand-written

lib/Controller/ContactpersonenController.php

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
use OCP\IUserManager;
3434
use OCP\IUserSession;
3535
use OCP\Security\ISecureRandom;
36-
use Psr\Container\ContainerInterface;
3736
use Psr\Log\LoggerInterface;
3837
use OCA\OpenRegister\Contract\ObjectServiceInterface;
3938
use OCA\OpenRegister\Service\OrganisationService;
@@ -111,13 +110,6 @@ class ContactpersonenController extends Controller {
111110
*/
112111
private IUserSession $userSession;
113112

114-
/**
115-
* Container for dependency injection.
116-
*
117-
* @var ContainerInterface
118-
*/
119-
private ContainerInterface $container;
120-
121113
/**
122114
* Contactpersoon service for business logic.
123115
*
@@ -136,7 +128,6 @@ class ContactpersonenController extends Controller {
136128
* @param IUserManager $userManager User manager
137129
* @param IGroupManager $groupManager Group manager
138130
* @param IUserSession $userSession User session
139-
* @param ContainerInterface $container Container for DI
140131
* @param ISecureRandom $secureRandom Secure random generator
141132
* @param LoggerInterface $logger Logger instance
142133
* @param ObjectServiceInterface $objectService OpenRegister object access (ADR-022/ADR-084 —
@@ -154,7 +145,6 @@ public function __construct(
154145
IUserManager $userManager,
155146
IGroupManager $groupManager,
156147
IUserSession $userSession,
157-
ContainerInterface $container,
158148
ISecureRandom $secureRandom,
159149
LoggerInterface $logger,
160150
private readonly ObjectServiceInterface $objectService,
@@ -167,7 +157,6 @@ public function __construct(
167157
$this->userManager = $userManager;
168158
$this->groupManager = $groupManager;
169159
$this->userSession = $userSession;
170-
$this->container = $container;
171160
$this->secureRandom = $secureRandom;
172161
$this->logger = $logger;
173162
}//end __construct()
@@ -515,7 +504,12 @@ public function convertToUser(string $contactPersonId): JSONResponse {
515504

516505
$contactData = $this->normaliseContactDataForPersist(contactData: $contactData);
517506

518-
$contactPersonObject->setObject($contactData);
507+
// No local `setObject()` on the entity here. `setObject()` is not on
508+
// the published ObjectEntityInterface (ADR-084) — it is an
509+
// implementation-only accessor reached through Entity::__call() — and
510+
// mutating the in-memory copy changed nothing anyway: the save below
511+
// sends `$contactData` itself, and the entity is only read again for
512+
// its UUID.
519513

520514
// Debug logging to understand data types before save.
521515
$lastNameValue = $contactData['achternaam'] ?? 'not set';
@@ -550,8 +544,7 @@ public function convertToUser(string $contactPersonId): JSONResponse {
550544
schema: $schemaId,
551545
uuid: $contactPersonObject->getUuid(),
552546
silent: true,
553-
silent: true,
554-
_validation: false
547+
_validation: false
555548
);
556549

557550
$this->logger->info(

lib/EventListener/UserProfileUpdatedEventListener.php

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@
2626
use Psr\Container\ContainerInterface;
2727
use Psr\Log\LoggerInterface;
2828
use OCA\OpenRegister\Contract\ObjectServiceInterface;
29-
use OCA\OpenRegister\Db\SchemaMapper;
30-
use OCA\OpenRegister\Db\RegisterMapper;
31-
use OCA\OpenRegister\Service\Object\SaveObject\MetadataHydrationHandler;
3229

3330
/**
3431
* Syncs user profile changes to the corresponding contactpersoon object.
@@ -58,16 +55,10 @@ class UserProfileUpdatedEventListener implements IEventListener {
5855
*
5956
* @param ContainerInterface $container DI container for lazy service resolution.
6057
* @param ObjectServiceInterface $objectService OpenRegister object access (ADR-084 contract).
61-
* @param SchemaMapper $schemaMapper Resolves the contactpersoon schema.
62-
* @param RegisterMapper $registerMapper Resolves the register the schema lives in.
63-
* @param MetadataHydrationHandler $metadataHydrationHandler Hydrates `@self` metadata on read.
6458
*/
6559
public function __construct(
6660
private readonly ContainerInterface $container,
6761
private readonly ObjectServiceInterface $objectService,
68-
private readonly SchemaMapper $schemaMapper,
69-
private readonly RegisterMapper $registerMapper,
70-
private readonly MetadataHydrationHandler $metadataHydrationHandler,
7162
) {
7263
}//end __construct()
7364

@@ -207,16 +198,16 @@ private function syncToContactPerson(UserProfileUpdatedEvent $event, LoggerInter
207198
]
208199
);
209200

210-
// Merge the patch into existing data and save directly via mapper to skip schema validation.
211-
// Schema validation can reject existing data with legacy values (e.g. notificaties enum).
201+
// Merge the patch into the existing payload. `saveObject()` is
202+
// PUT-semantic, so the FULL payload must be carried forward, not only
203+
// the changed keys. Validation is skipped on the save because schema
204+
// validation can reject pre-existing legacy values (e.g. notificaties
205+
// enum) that this listener never touched.
212206
$mergedObject = array_merge($contactData, $patch);
213-
$contactPerson->setObject($mergedObject);
214207

215208
$this->persistContactPersonPatch(
216209
contactPerson: $contactPerson,
217-
register: (int)$register,
218-
schema: (int)$contactPersonSchema,
219-
logger: $logger
210+
object: $mergedObject
220211
);
221212

222213
$logger->info(
@@ -278,63 +269,39 @@ private function buildContactPatch(
278269
}//end buildContactPatch()
279270

280271
/**
281-
* Persist the patched contactpersoon entity, regenerating `_name`
282-
* metadata first when the schema is loadable.
272+
* Persist the patched contactpersoon payload through the published
273+
* OpenRegister contract.
283274
*
284-
* @param object $contactPerson The contactpersoon entity.
285-
* @param int $register The voorzieningen register id.
286-
* @param int $schema The contactpersoon schema id.
287-
* @param LoggerInterface $logger Logger for hydration warnings.
275+
* `_name` metadata is NOT regenerated here. This method used to load the
276+
* schema through `SchemaMapper` and call
277+
* `MetadataHydrationHandler::hydrateObjectMetadata()` itself — both of them
278+
* OpenRegister internals that ADR-084's published contract deliberately does
279+
* not expose, and neither of which a leaf app can load in its own unit
280+
* tests. It was also redundant: `ObjectService::saveObject()` calls
281+
* `hydrateObjectMetadata()` on both its create and its update path before
282+
* handing the entity to `objectEntityMapper`, so the hydration happens
283+
* exactly once either way — it was simply being done twice, one layer too
284+
* deep.
285+
*
286+
* @param object $contactPerson The contactpersoon entity (read for its coordinates).
287+
* @param array $object The full merged payload to store (PUT-semantic).
288288
*
289289
* @return void
290290
*/
291291
private function persistContactPersonPatch(
292292
object $contactPerson,
293-
int $register,
294-
int $schema,
295-
LoggerInterface $logger,
293+
array $object,
296294
): void {
297-
$schemaEntity = null;
298-
$registerEntity = null;
299-
try {
300-
$schemaEntity = $this->schemaMapper->find(id: $schema, _rbac: false, _multitenancy: false);
301-
$registerEntity = $this->registerMapper->find(id: $register, _rbac: false, _multitenancy: false);
302-
} catch (\Exception $e) {
303-
$logger->warning(
304-
'[UserProfileUpdatedEventListener] Could not load schema/register entities for _name hydration',
305-
[
306-
'error' => $e->getMessage(),
307-
]
308-
);
309-
}
310-
311-
if ($schemaEntity !== null) {
312-
$this->metadataHydrationHandler->hydrateObjectMetadata(entity: $contactPerson, schema: $schemaEntity);
313-
$logger->debug(
314-
'[UserProfileUpdatedEventListener] Regenerated _name metadata',
315-
[
316-
'newName' => $contactPerson->getName(),
317-
]
318-
);
319-
}
320-
321-
// Persist through the published contract rather than OpenRegister's Db
322-
// layer. The comment this replaces worried that a plain save would touch
323-
// "just the blob table" — it does not: ObjectService::saveObject() calls
324-
// metaHydrationHandler->hydrateObjectMetadata() and then
325-
// objectEntityMapper->update(entity:, register:, schema:), which IS the
326-
// magic-mapper route. This listener was hand-rolling OpenRegister's own
327-
// save pipeline, one layer too deep.
328295
$this->objectService->saveObject(
329-
object: $contactPerson->getObject(),
296+
object: $object,
330297
register: $contactPerson->getRegister(),
331298
schema: $contactPerson->getSchema(),
332299
uuid: $contactPerson->getUuid(),
333300
silent: true,
334301
_validation: false
335302
);
336303

337-
}//end persistContactpersoonPatch()
304+
}//end persistContactPersonPatch()
338305

339306
/**
340307
* Find a contactpersoon by username, falling back to a case-insensitive email search.

lib/Service/AanbodService.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,12 @@ public function acceptAanbod(string $aanbodId, array $options = []): array {
374374
);
375375

376376
// Save the updated object with RBAC and multitenancy disabled.
377-
$existingAanbod->setObject($aanbodData);
377+
// The PAYLOAD is what is sent — `setObject()` on the entity is not on
378+
// the published ObjectEntityInterface (ADR-084) and the mutated copy
379+
// was never read back anyway; `saveObject()` is PUT-semantic, so
380+
// $aanbodData already carries every unchanged field forward.
378381
$updatedAanbod = $objectService->saveObject(
379-
object: $existingAanbod,
382+
object: $aanbodData,
380383
register: $existingAanbod->getRegister(),
381384
schema: $existingAanbod->getSchema(),
382385
uuid: $aanbodId,
@@ -593,12 +596,12 @@ private function resolvePartyId(mixed $partyInfo): ?string {
593596
* @param ObjectServiceInterface $objectService The OpenRegister object service
594597
* @param string $aanbodId The UUID of the aanbod object
595598
*
596-
* @return \OCA\OpenRegister\Db\ObjectEntity|null The found object or null
599+
* @return \OCA\OpenRegister\Contract\ObjectEntityInterface|null The found object or null
597600
*/
598601
private function findAanbodObject(
599602
ObjectServiceInterface $objectService,
600603
string $aanbodId,
601-
): ?\OCA\OpenRegister\Db\ObjectEntity {
604+
): ?\OCA\OpenRegister\Contract\ObjectEntityInterface {
602605
$voorzieningenConfig = $this->settingsService->getVoorzieningenConfig();
603606
$registerId = $voorzieningenConfig['register'] ?? null;
604607

lib/Service/AangebodenGebruikService.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -992,9 +992,11 @@ public function setGebruikSelfToActiveOrg(string $gebruikId, array $options = []
992992

993993
// Save the updated object with RBAC and multitenancy disabled.
994994
// Use register/schema from the found entity for correct table routing.
995-
$existingGebruik->setObject($gebruikData);
995+
// The PAYLOAD is what is sent — `setObject()` is not on the published
996+
// ObjectEntityInterface (ADR-084), and `saveObject()` is PUT-semantic,
997+
// so $gebruikData already carries every unchanged field forward.
996998
$updatedGebruik = $objectService->saveObject(
997-
object: $existingGebruik,
999+
object: $gebruikData,
9981000
register: $existingGebruik->getRegister(),
9991001
schema: $existingGebruik->getSchema(),
10001002
uuid: $gebruikId,
@@ -1049,12 +1051,12 @@ public function setGebruikSelfToActiveOrg(string $gebruikId, array $options = []
10491051
* @param ObjectServiceInterface $objectService The OpenRegister object service
10501052
* @param string $objectId The UUID of the object to find
10511053
*
1052-
* @return \OCA\OpenRegister\Db\ObjectEntity|null The found object or null
1054+
* @return \OCA\OpenRegister\Contract\ObjectEntityInterface|null The found object or null
10531055
*/
10541056
private function findGebruikOrIntegration(
10551057
ObjectServiceInterface $objectService,
10561058
string $objectId,
1057-
): ?\OCA\OpenRegister\Db\ObjectEntity {
1059+
): ?\OCA\OpenRegister\Contract\ObjectEntityInterface {
10581060
$voorzieningenConfig = $this->settingsService->getVoorzieningenConfig();
10591061
$registerId = $voorzieningenConfig['register'] ?? null;
10601062

lib/Service/ContactpersoonService.php

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use OCA\SoftwareCatalog\Service\SoftwareCatalogue\GroupHandler;
2626
use OCA\SoftwareCatalog\Service\SoftwareCatalogue\HierarchyHandler;
2727
use OCP\App\IAppManager;
28+
use OCP\AppFramework\Db\DoesNotExistException;
2829
use OCP\IAppConfig;
2930
use Psr\Container\ContainerInterface;
3031
use Psr\Log\LoggerInterface;
@@ -1026,15 +1027,24 @@ public function getBulkUserInfo(array $contactPersonIds): array {
10261027
}
10271028

10281029
// Find the contactpersoon object with register and schema specified.
1029-
$contactObject = $objectService->findSilent(
1030-
id: $contactPersonId,
1031-
_extend: [],
1032-
files: false,
1033-
register: $contactRegister,
1034-
schema: $contactSchema
1035-
);
1036-
1037-
if ($contactObject === null) {
1030+
//
1031+
// A MISS RAISES, it does not return null: `findSilent()` is
1032+
// declared `ObjectEntityInterface` (non-nullable) on the
1033+
// published contract and OpenRegister lets the mapper's
1034+
// DoesNotExistException out. The `=== null` test this replaces
1035+
// could therefore never be true — the distinct "not found"
1036+
// entry below was unreachable, and every missing contactpersoon
1037+
// fell through to the generic error arm and came back carrying
1038+
// an `error` key instead.
1039+
try {
1040+
$contactObject = $objectService->findSilent(
1041+
id: $contactPersonId,
1042+
_extend: [],
1043+
files: false,
1044+
register: $contactRegister,
1045+
schema: $contactSchema
1046+
);
1047+
} catch (DoesNotExistException $e) {
10381048
$this->logger->warning(
10391049
'ContactpersoonService: Contactpersoon not found for bulk user info',
10401050
['contactpersoonId' => $contactPersonId]
@@ -1045,7 +1055,7 @@ public function getBulkUserInfo(array $contactPersonIds): array {
10451055
'groups' => [],
10461056
];
10471057
continue;
1048-
}
1058+
}//end try
10491059

10501060
$contactData = $contactObject->getObject();
10511061
$username = $contactData['username'] ?? null;

lib/Service/ContractApprovalService.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
namespace OCA\SoftwareCatalog\Service;
3939

4040
use OCA\OpenRegister\Contract\ObjectServiceInterface;
41-
use OCA\OpenRegister\Service\ObjectService;
4241
use OCP\EventDispatcher\IEventDispatcher;
4342
use Psr\Container\ContainerInterface;
4443
use Psr\Log\LoggerInterface;
@@ -501,7 +500,7 @@ private function buildSubjectLabel(array $data): string {
501500
*
502501
* @param string $contractUuid The contract uuid.
503502
*
504-
* @return \OCA\OpenRegister\Db\ObjectEntity|null The object, or null.
503+
* @return \OCA\OpenRegister\Contract\ObjectEntityInterface|null The object, or null.
505504
*
506505
* @spec openspec/specs/contract-decision-delegation/spec.md
507506
*/
@@ -540,7 +539,7 @@ private function loadContract(string $contractUuid) {
540539
/**
541540
* Persist a mutated contract object back to the OR store.
542541
*
543-
* @param \OCA\OpenRegister\Db\ObjectEntity $contract The contract entity.
542+
* @param \OCA\OpenRegister\Contract\ObjectEntityInterface $contract The contract entity.
544543
* @param array $data The mutated object data.
545544
*
546545
* @return void
@@ -572,8 +571,14 @@ private function persistContract($contract, array $data): void {
572571
*/
573572
private function getObjectService(): ?ObjectServiceInterface {
574573
try {
575-
$service = $this->container->get('OCA\OpenRegister\Service\ObjectService');
576-
if ($service instanceof ObjectService) {
574+
// Ask for the CONTRACT and narrow on the CONTRACT (ADR-084). Asking
575+
// for the concrete class and gating on `instanceof ObjectService`
576+
// made this method return null for anything that satisfies the
577+
// published interface without being that exact class — which is
578+
// every double a leaf app can build, so the ownership check silently
579+
// fell through to its fail-closed arm and refused an owner.
580+
$service = $this->container->get(ObjectServiceInterface::class);
581+
if ($service instanceof ObjectServiceInterface) {
577582
return $service;
578583
}
579584
} catch (\Throwable $e) {

lib/Service/ContractStatusService.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828

2929
use DateTimeImmutable;
3030
use OCA\OpenRegister\Contract\ObjectServiceInterface;
31-
use OCA\OpenRegister\Service\ObjectService;
3231
use Psr\Container\ContainerInterface;
3332
use Psr\Log\LoggerInterface;
3433

@@ -189,8 +188,11 @@ public function expirePastContracts(?DateTimeImmutable $now = null): int {
189188
*/
190189
private function getObjectService(): ?ObjectServiceInterface {
191190
try {
192-
$service = $this->container->get('OCA\OpenRegister\Service\ObjectService');
193-
if ($service instanceof ObjectService) {
191+
// Ask for the CONTRACT and narrow on the CONTRACT (ADR-084) — see
192+
// ContractApprovalService::getObjectService() for why gating on the
193+
// concrete class is a silent fail-closed.
194+
$service = $this->container->get(ObjectServiceInterface::class);
195+
if ($service instanceof ObjectServiceInterface) {
194196
return $service;
195197
}
196198
} catch (\Throwable $e) {

0 commit comments

Comments
 (0)