From 0cfbb93cfb3f8ba424e9210145abebe93de9e243 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 1 Aug 2026 23:37:05 +0200 Subject: [PATCH 1/2] fix(events): resolve the schema slug listeners compare against (gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductionVersionGuardListener and AutomationCleanupListener each carried a private extractSchemaSlug() that probed for ObjectEntity::getSchemaSlug() — a method that does not exist — and then fell back to `@self.schema`, which is the schema's numeric id. Both then compared that id with `!==` against a slug literal ('application', 'automation'), so the comparison was always true and neither handler body has ever executed. No exception, no log line. Replaces both helpers with one shared ObjectSchemaSlugResolver that resolves the id to a slug via SchemaMapper::find() (request-cached, and memoised here including misses so a hot write path does not become an N+1). The resolver matches the REGISTER as well as the schema. A schema slug is not unique instance-wide: this instance carries two distinct schemas with the slug `automation` (ids 71 and 5103), so matching on the schema slug alone would fire OpenBuild's handlers for another app's objects. Mirrors the register+schema pair pattern already shipped in petstore and planix. GATED, DEFAULT OFF — `openbuild.listener_slug_contract`. Correcting the comparison is not behaviour-neutral. ProductionVersionGuardListener is a FAIL-CLOSED validation guard: it fails OPEN today, so mismatched production versions are never blocked, and waking it starts REJECTING writes that currently succeed. AutomationCleanupListener starts DELETING compiled artifacts on automation delete. Neither path has ever run, so neither has been exercised against real data. The flag lets the fix ship and be reviewed without switching all of that on in one deploy, mirroring openregister#2248's approach to the sibling ObjectTransitionedEvent defect. Not addressed here: DocumentGenerationListener and AutomationApprovalTriggerListener have the same id-vs-slug defect via schemaOf(), but they feed the id into automation trigger matching rather than a literal comparison, so they need a separate change. --- lib/Listener/AutomationCleanupListener.php | 57 +++-- .../ProductionVersionGuardListener.php | 60 +++--- lib/Service/ListenerSlugContract.php | 81 +++++++ lib/Service/ObjectSchemaSlugResolver.php | 199 ++++++++++++++++++ 4 files changed, 336 insertions(+), 61 deletions(-) create mode 100644 lib/Service/ListenerSlugContract.php create mode 100644 lib/Service/ObjectSchemaSlugResolver.php diff --git a/lib/Listener/AutomationCleanupListener.php b/lib/Listener/AutomationCleanupListener.php index 2e2b7a783..b29517f29 100644 --- a/lib/Listener/AutomationCleanupListener.php +++ b/lib/Listener/AutomationCleanupListener.php @@ -41,6 +41,8 @@ namespace OCA\OpenBuild\Listener; use OCA\OpenBuild\Service\AutomationCompilerService; +use OCA\OpenBuild\Service\ListenerSlugContract; +use OCA\OpenBuild\Service\ObjectSchemaSlugResolver; use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; @@ -59,12 +61,16 @@ class AutomationCleanupListener implements IEventListener * * @param LoggerInterface $logger PSR logger for diagnostics. * @param AutomationCompilerService $compiler Owns the artifact-removal logic. + * @param ObjectSchemaSlugResolver $slugs Resolves the event's schema id to a slug. + * @param ListenerSlugContract $contract Gates the corrected comparison. * * @return void */ public function __construct( private readonly LoggerInterface $logger, private readonly AutomationCompilerService $compiler, + private readonly ObjectSchemaSlugResolver $slugs, + private readonly ListenerSlugContract $contract, ) { }//end __construct() @@ -83,8 +89,27 @@ public function handle(Event $event): void } $entity = $event->getObject(); - $schema = $this->extractSchemaSlug(entity: $entity); - if ($schema !== AutomationCompilerService::AUTOMATION_SCHEMA) { + + // GATED ON PURPOSE — see ListenerSlugContract. + // + // extractSchemaSlug() returned the schema's numeric id and compared it + // to the slug 'automation', so this cleanup has never once run and + // every deleted automation has left its compiled artifacts behind. The + // comparison below is correct; enabling it starts DELETING those + // artifacts on automation delete, which is the desired behaviour but is + // still a behaviour change on a path that has never executed. + if ($this->contract->isEnabled() === false) { + return; + } + + // The register is checked as well as the schema: `automation` is not a + // unique slug on this instance (two schemas carry it), so matching on + // the schema slug alone would delete artifacts for another app's rows. + if ($this->slugs->isOpenBuildSchema( + entity: $entity, + schemaSlug: AutomationCompilerService::AUTOMATION_SCHEMA + ) === false + ) { return; } @@ -105,34 +130,6 @@ public function handle(Event $event): void } }//end handle() - /** - * Read the schema slug from the ObjectEntity (defensive — supports both - * direct `getSchemaSlug()` and the `@self.schema` projection, mirroring - * {@see ProductionVersionGuardListener::extractSchemaSlug()}). - * - * @param object $entity The ObjectEntity instance. - * - * @return string Schema slug or empty string when unresolved. - */ - private function extractSchemaSlug(object $entity): string - { - if (method_exists($entity, 'getSchemaSlug') === true) { - $slug = $entity->getSchemaSlug(); - if (is_string($slug) === true && $slug !== '') { - return $slug; - } - } - - if (method_exists($entity, 'jsonSerialize') === true) { - $serialised = $entity->jsonSerialize(); - if (is_array($serialised) === true && isset($serialised['@self']['schema']) === true) { - return (string) $serialised['@self']['schema']; - } - } - - return ''; - }//end extractSchemaSlug() - /** * Read the object payload (post-`@self`) from the ObjectEntity. * diff --git a/lib/Listener/ProductionVersionGuardListener.php b/lib/Listener/ProductionVersionGuardListener.php index 16fffd78e..730fe5bae 100644 --- a/lib/Listener/ProductionVersionGuardListener.php +++ b/lib/Listener/ProductionVersionGuardListener.php @@ -40,6 +40,8 @@ namespace OCA\OpenBuild\Listener; use OCA\OpenBuild\Service\ApplicationVersionService; +use OCA\OpenBuild\Service\ListenerSlugContract; +use OCA\OpenBuild\Service\ObjectSchemaSlugResolver; use OCA\OpenRegister\Event\ObjectCreatingEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; use OCP\EventDispatcher\Event; @@ -57,14 +59,18 @@ class ProductionVersionGuardListener implements IEventListener /** * Constructor. * - * @param LoggerInterface $logger PSR logger for diagnostics - * @param ApplicationVersionService $service The cross-row guard owner + * @param LoggerInterface $logger PSR logger for diagnostics + * @param ApplicationVersionService $service The cross-row guard owner + * @param ObjectSchemaSlugResolver $slugs Resolves the event's schema id to a slug + * @param ListenerSlugContract $contract Gates the corrected comparison * * @return void */ public function __construct( private readonly LoggerInterface $logger, private readonly ApplicationVersionService $service, + private readonly ObjectSchemaSlugResolver $slugs, + private readonly ListenerSlugContract $contract, ) { }//end __construct() @@ -96,8 +102,27 @@ public function handle(Event $event): void return; } - $schema = $this->extractSchemaSlug(entity: $entity); - if ($schema !== ApplicationVersionService::APPLICATION_SCHEMA) { + // GATED ON PURPOSE — read before flipping the flag. + // + // This guard has never once executed: extractSchemaSlug() returned the + // schema's numeric id and compared it to the slug 'application', so the + // `!==` was always true and this method always returned here. The + // comparison below is now correct, but making it correct CHANGES + // BEHAVIOUR: this is a fail-closed validation guard, so waking it + // starts REJECTING production-version writes that succeed today. + // + // Enabling it is therefore a rollout decision, not a bug fix, and it is + // deliberately off by default. Enable with: + // occ config:app:set openbuild listener_slug_contract --value=yes. + if ($this->contract->isEnabled() === false) { + return; + } + + if ($this->slugs->isOpenBuildSchema( + entity: $entity, + schemaSlug: ApplicationVersionService::APPLICATION_SCHEMA + ) === false + ) { return; } @@ -143,33 +168,6 @@ public function handle(Event $event): void }//end try }//end handle() - /** - * Read the schema slug from the ObjectEntity (defensive — supports - * both direct `getSchemaSlug()` and the `@self.schema` projection). - * - * @param object $entity The ObjectEntity instance - * - * @return string Schema slug or empty string when unresolved - */ - private function extractSchemaSlug(object $entity): string - { - if (method_exists($entity, 'getSchemaSlug') === true) { - $slug = $entity->getSchemaSlug(); - if (is_string($slug) === true && $slug !== '') { - return $slug; - } - } - - if (method_exists($entity, 'jsonSerialize') === true) { - $serialised = $entity->jsonSerialize(); - if (is_array($serialised) === true && isset($serialised['@self']['schema']) === true) { - return (string) $serialised['@self']['schema']; - } - } - - return ''; - }//end extractSchemaSlug() - /** * Read the object payload (post-`@self`) from the ObjectEntity. * diff --git a/lib/Service/ListenerSlugContract.php b/lib/Service/ListenerSlugContract.php new file mode 100644 index 000000000..299913e3a --- /dev/null +++ b/lib/Service/ListenerSlugContract.php @@ -0,0 +1,81 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://openbuild.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuild\Service; + +use OCP\IAppConfig; + +/** + * Reports whether the corrected listener schema matching is enabled. + */ +class ListenerSlugContract +{ + + /** + * The app id the flag is stored under. + * + * @var string + */ + private const APP_ID = 'openbuild'; + + /** + * The config key holding the flag. + * + * @var string + */ + private const CONFIG_KEY = 'listener_slug_contract'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig Nextcloud app configuration. + */ + public function __construct(private readonly IAppConfig $appConfig) + { + }//end __construct() + + /** + * Whether the corrected slug comparison should be honoured. + * + * @return bool True when the contract is enabled for this instance. + */ + public function isEnabled(): bool + { + return $this->appConfig->getValueBool(self::APP_ID, self::CONFIG_KEY, false); + }//end isEnabled() +}//end class diff --git a/lib/Service/ObjectSchemaSlugResolver.php b/lib/Service/ObjectSchemaSlugResolver.php new file mode 100644 index 000000000..daef8ccbe --- /dev/null +++ b/lib/Service/ObjectSchemaSlugResolver.php @@ -0,0 +1,199 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://openbuild.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuild\Service; + +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Turns the register/schema ids an OpenRegister event carries into slugs. + */ +class ObjectSchemaSlugResolver +{ + + /** + * The register slug openbuild's own objects live in. + * + * @var string + */ + public const REGISTER_SLUG = 'openbuild'; + + /** + * Resolved slugs keyed by ":", for the request lifetime. + * + * The mappers cache too, but memoising here also caches the MISSES, so a + * payload referencing a schema this instance does not have costs one failed + * lookup per request rather than one per event. openbuild's listeners fire + * on every object write, so an unmemoised lookup is an N+1 on bulk imports + * (docudesk measured 1,471 SchemaMapper::find() calls per object save from + * exactly this shape). + * + * @var array + */ + private array $slugs = []; + + /** + * Constructor. + * + * @param ContainerInterface $container The DI container, used to reach + * OpenRegister's mappers lazily so + * openbuild still boots without it. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Resolve the schema slug for an ObjectEntity. + * + * @param object $entity The OpenRegister ObjectEntity. + * + * @return string The schema slug, or '' when unresolvable. An empty string + * never equals a slug literal, so an unresolvable schema + * keeps the handler's existing fail-closed behaviour. + */ + public function schemaSlug(object $entity): string + { + if (method_exists($entity, 'getSchema') === false) { + return ''; + } + + return $this->resolve( + mapper: 'OCA\OpenRegister\Db\SchemaMapper', + id: (string) $entity->getSchema() + ); + }//end schemaSlug() + + /** + * Resolve the register slug for an ObjectEntity. + * + * @param object $entity The OpenRegister ObjectEntity. + * + * @return string The register slug, or '' when unresolvable. + */ + public function registerSlug(object $entity): string + { + if (method_exists($entity, 'getRegister') === false) { + return ''; + } + + return $this->resolve( + mapper: 'OCA\OpenRegister\Db\RegisterMapper', + id: (string) $entity->getRegister() + ); + }//end registerSlug() + + /** + * Test whether an entity is an openbuild object of the given schema. + * + * @param object $entity The OpenRegister ObjectEntity. + * @param string $schemaSlug The schema slug to match. + * + * @return bool True when the entity is that schema in the openbuild register. + */ + public function isOpenBuildSchema(object $entity, string $schemaSlug): bool + { + if ($this->schemaSlug(entity: $entity) !== $schemaSlug) { + return false; + } + + // Guard the register too: `automation` is not a unique slug instance-wide. + return $this->registerSlug(entity: $entity) === self::REGISTER_SLUG; + }//end isOpenBuildSchema() + + /** + * Resolve a slug from an id via one of OpenRegister's mappers. + * + * @param string $mapper Fully-qualified mapper class name. + * @param string $id The register or schema id. + * + * @return string The slug, or '' when unresolvable. + */ + private function resolve(string $mapper, string $id): string + { + $id = trim($id); + if ($id === '') { + return ''; + } + + // A non-numeric value is already a slug; ids are always digits. This + // keeps the resolver correct if OpenRegister ever starts emitting slugs. + if (ctype_digit($id) === false) { + return $id; + } + + $key = $mapper.':'.$id; + if (array_key_exists($key, $this->slugs) === true) { + return $this->slugs[$key]; + } + + $slug = ''; + + try { + // Signature is find($id, $_extend, $_rbac, $_multitenancy). RBAC and + // multitenancy are off: this runs inside an event handler that may + // have no active organisation, and a register/schema slug is + // metadata rather than tenant data. An organisation-scoped read + // would return nothing and silently reopen the same hole. + $entity = $this->container->get($mapper)->find($id, [], false, false); + if (is_object($entity) === true && method_exists($entity, 'getSlug') === true) { + $slug = (string) $entity->getSlug(); + } + } catch (\Throwable $e) { + $this->logger->debug( + 'OpenBuild: could not resolve slug for '.$mapper.' id '.$id, + ['exception' => $e->getMessage()] + ); + } + + $this->slugs[$key] = $slug; + + return $slug; + }//end resolve() +}//end class From 2bb2016d1b287b83a9bf8f0dfdc38809d5ae4fe9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 2 Aug 2026 09:15:57 +0200 Subject: [PATCH 2/2] test(events): repair the constructor break and pin the default-off gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both listeners gained two constructor params (the slug resolver and the opt-in contract) but neither test was updated — 7 ArgumentCountError errors, so CI was red on all six PHP/NC combinations. The old tests were also not a control. They fed `'@self' => ['schema' => 'automation']` — a SLUG — where MagicMapper writes a numeric ID, which is the exact reason these listeners never ran in production. Passing the fixture the production shape would not produce is how a dead listener tests green. The fixtures now carry ids and the resolver decides, which is what the shipped code does. Added a default-off test per listener asserting that nothing happens at all — not even the slug lookup. That is the merge-safety assertion: the production version guard is fail-closed and currently fails open, so waking it starts rejecting writes that succeed today. 739 tests, 0 failures (was 737 with 7 errors). --- .../AutomationCleanupListenerTest.php | 88 ++++++++++++++++++- .../ProductionVersionGuardListenerTest.php | 78 ++++++++++++++-- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/tests/Unit/Listener/AutomationCleanupListenerTest.php b/tests/Unit/Listener/AutomationCleanupListenerTest.php index af2ec241c..a8992977c 100644 --- a/tests/Unit/Listener/AutomationCleanupListenerTest.php +++ b/tests/Unit/Listener/AutomationCleanupListenerTest.php @@ -27,6 +27,8 @@ use OCA\OpenBuild\Listener\AutomationCleanupListener; use OCA\OpenBuild\Service\AutomationCompilerService; +use OCA\OpenBuild\Service\ListenerSlugContract; +use OCA\OpenBuild\Service\ObjectSchemaSlugResolver; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Event\ObjectDeletedEvent; use OCA\OpenRegister\Event\ObjectUpdatingEvent; @@ -51,18 +53,84 @@ final class AutomationCleanupListenerTest extends TestCase */ private AutomationCleanupListener $listener; + /** + * Resolver double: turns the entity's schema id into a slug. + * + * @var ObjectSchemaSlugResolver&MockObject + */ + private ObjectSchemaSlugResolver&MockObject $slugs; + + /** + * Opt-in flag double for the corrected slug comparison. + * + * @var ListenerSlugContract&MockObject + */ + private ListenerSlugContract&MockObject $contract; + /** * Set up mocks + SUT. * + * The contract defaults to ENABLED here so the tests below exercise the + * cleanup path itself. `testDoesNothingWhenContractDisabled()` covers the + * shipped default, which is off. + * * @return void */ protected function setUp(): void { $this->compiler = $this->createMock(AutomationCompilerService::class); - $this->listener = new AutomationCleanupListener($this->createMock(LoggerInterface::class), $this->compiler); + $this->slugs = $this->createMock(ObjectSchemaSlugResolver::class); + $this->contract = $this->createMock(ListenerSlugContract::class); + $this->contract->method('isEnabled')->willReturn(true); + + $this->listener = new AutomationCleanupListener( + $this->createMock(LoggerInterface::class), + $this->compiler, + $this->slugs, + $this->contract + ); }//end setUp() + /** + * The shipped default is OFF: an automation delete must change nothing, + * because waking this listener starts deleting compiled artifacts on a + * path that has never executed. + * + * @return void + */ + public function testDoesNothingWhenContractDisabled(): void + { + $compiler = $this->createMock(AutomationCompilerService::class); + $slugs = $this->createMock(ObjectSchemaSlugResolver::class); + $contract = $this->createMock(ListenerSlugContract::class); + $contract->method('isEnabled')->willReturn(false); + + $listener = new AutomationCleanupListener( + $this->createMock(LoggerInterface::class), + $compiler, + $slugs, + $contract + ); + + $automation = [ + '@self' => ['schema' => '116'], + 'slug' => 'notify-caseworkers', + 'provenance' => ['notificationKeys' => [['schema' => 'permit', 'key' => 'k']]], + ]; + + $entity = $this->createMock(ObjectEntity::class); + $entity->method('jsonSerialize')->willReturn($automation); + $entity->method('getObject')->willReturn($automation); + + // Not even the slug lookup happens — the gate returns first. + $slugs->expects($this->never())->method('isOpenBuildSchema'); + $compiler->expects($this->never())->method('remove'); + + $listener->handle(new ObjectDeletedEvent($entity)); + + }//end testDoesNothingWhenContractDisabled() + /** * A deleted `automation` row triggers compiler removal with its * provenance block. @@ -71,8 +139,11 @@ protected function setUp(): void */ public function testRemovesArtifactsForDeletedAutomation(): void { + // `@self.schema` carries the NUMERIC ID, as MagicMapper writes it — + // the old test fed a slug here, which is why it passed against a + // listener that could never match in production. $automation = [ - '@self' => ['schema' => 'automation'], + '@self' => ['schema' => '116'], 'slug' => 'notify-caseworkers', 'provenance' => ['notificationKeys' => [['schema' => 'permit', 'key' => 'aut-notify-caseworkers-1']]], ]; @@ -81,6 +152,11 @@ public function testRemovesArtifactsForDeletedAutomation(): void $entity->method('jsonSerialize')->willReturn($automation); $entity->method('getObject')->willReturn($automation); + $this->slugs->expects($this->once()) + ->method('isOpenBuildSchema') + ->with($entity, AutomationCompilerService::AUTOMATION_SCHEMA) + ->willReturn(true); + $event = new ObjectDeletedEvent($entity); $this->compiler->expects($this->once()) @@ -102,9 +178,11 @@ public function testRemovesArtifactsForDeletedAutomation(): void public function testIgnoresNonAutomationSchema(): void { $entity = $this->createMock(ObjectEntity::class); - $entity->method('jsonSerialize')->willReturn(['@self' => ['schema' => 'application']]); + $entity->method('jsonSerialize')->willReturn(['@self' => ['schema' => '117']]); $entity->method('getObject')->willReturn([]); + $this->slugs->method('isOpenBuildSchema')->willReturn(false); + $event = new ObjectDeletedEvent($entity); $this->compiler->expects($this->never())->method('remove'); @@ -136,11 +214,13 @@ public function testIgnoresOtherEventTypes(): void */ public function testCompilerFailureIsSwallowed(): void { - $automation = ['@self' => ['schema' => 'automation'], 'slug' => 'broken']; + $automation = ['@self' => ['schema' => '116'], 'slug' => 'broken']; $entity = $this->createMock(ObjectEntity::class); $entity->method('jsonSerialize')->willReturn($automation); $entity->method('getObject')->willReturn($automation); + $this->slugs->method('isOpenBuildSchema')->willReturn(true); + $event = new ObjectDeletedEvent($entity); $this->compiler->method('remove')->willThrowException(new \RuntimeException('boom')); diff --git a/tests/Unit/Listener/ProductionVersionGuardListenerTest.php b/tests/Unit/Listener/ProductionVersionGuardListenerTest.php index 6c8490989..50b75c49a 100644 --- a/tests/Unit/Listener/ProductionVersionGuardListenerTest.php +++ b/tests/Unit/Listener/ProductionVersionGuardListenerTest.php @@ -27,6 +27,8 @@ use OCA\OpenBuild\Listener\ProductionVersionGuardListener; use OCA\OpenBuild\Service\ApplicationVersionService; +use OCA\OpenBuild\Service\ListenerSlugContract; +use OCA\OpenBuild\Service\ObjectSchemaSlugResolver; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Event\ObjectUpdatingEvent; use PHPUnit\Framework\MockObject\MockObject; @@ -58,24 +60,81 @@ class ProductionVersionGuardListenerTest extends TestCase */ private ProductionVersionGuardListener $listener; + /** + * Resolver double: turns the entity's schema id into a slug. + * + * @var ObjectSchemaSlugResolver&MockObject + */ + private ObjectSchemaSlugResolver&MockObject $slugs; + + /** + * Opt-in flag double for the corrected slug comparison. + * + * @var ListenerSlugContract&MockObject + */ + private ListenerSlugContract&MockObject $contract; + /** * Set up mocks + SUT. * + * The contract defaults to ENABLED here so the tests below exercise the + * guard itself. `testGuardStaysDormantWhenContractDisabled()` covers the + * shipped default, which is off — this guard is fail-closed and waking it + * starts rejecting production-version writes that succeed today. + * * @return void */ protected function setUp(): void { parent::setUp(); - $this->logger = $this->createMock(LoggerInterface::class); - $this->service = $this->createMock(ApplicationVersionService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->service = $this->createMock(ApplicationVersionService::class); + $this->slugs = $this->createMock(ObjectSchemaSlugResolver::class); + $this->contract = $this->createMock(ListenerSlugContract::class); + $this->contract->method('isEnabled')->willReturn(true); $this->listener = new ProductionVersionGuardListener( logger: $this->logger, service: $this->service, + slugs: $this->slugs, + contract: $this->contract, ); }//end setUp() + /** + * The shipped default is OFF: a mismatching productionVersion must still + * be allowed through, exactly as today. + * + * @return void + */ + public function testGuardStaysDormantWhenContractDisabled(): void + { + $service = $this->createMock(ApplicationVersionService::class); + $slugs = $this->createMock(ObjectSchemaSlugResolver::class); + $contract = $this->createMock(ListenerSlugContract::class); + $contract->method('isEnabled')->willReturn(false); + + $listener = new ProductionVersionGuardListener( + logger: $this->createMock(LoggerInterface::class), + service: $service, + slugs: $slugs, + contract: $contract, + ); + + $entity = $this->createMock(ObjectEntity::class); + $entity->method('jsonSerialize')->willReturn(['@self' => ['schema' => '116']]); + $entity->method('getObject')->willReturn(['productionVersion' => 'uuid-other']); + + $slugs->expects(self::never())->method('isOpenBuildSchema'); + $service->expects(self::never())->method('guardProductionVersionOwnership'); + + $event = new ObjectUpdatingEvent($entity); + $listener->handle($event); + + self::assertFalse($event->isPropagationStopped()); + }//end testGuardStaysDormantWhenContractDisabled() + /** * Guard skips events for non-Application schemas (no service call). * @@ -85,11 +144,13 @@ public function testIgnoresNonApplicationSchema(): void { $entity = $this->createMock(ObjectEntity::class); $entity->method('jsonSerialize')->willReturn([ - '@self' => ['schema' => 'applicationVersion'], + '@self' => ['schema' => '117'], 'productionVersion' => 'uuid-v', ]); $entity->method('getObject')->willReturn(['productionVersion' => 'uuid-v']); + $this->slugs->method('isOpenBuildSchema')->willReturn(false); + $event = new ObjectUpdatingEvent($entity); $this->service->expects(self::never())->method('guardProductionVersionOwnership'); @@ -107,10 +168,12 @@ public function testSkipsWhenProductionVersionAbsent(): void { $entity = $this->createMock(ObjectEntity::class); $entity->method('jsonSerialize')->willReturn([ - '@self' => ['schema' => 'application'], + '@self' => ['schema' => '116'], ]); $entity->method('getObject')->willReturn(['slug' => 'foo']); + $this->slugs->method('isOpenBuildSchema')->willReturn(true); + $event = new ObjectUpdatingEvent($entity); $this->service->expects(self::never())->method('guardProductionVersionOwnership'); @@ -136,11 +199,16 @@ public function testStopsPropagationOnGuardFailure(): void ->onlyMethods(['jsonSerialize', 'getObject', 'getUuid']) ->getMock(); $entity->method('jsonSerialize')->willReturn([ - '@self' => ['schema' => 'application'], + '@self' => ['schema' => '116'], ]); $entity->method('getObject')->willReturn(['productionVersion' => 'uuid-other']); $entity->method('getUuid')->willReturn('uuid-this-app'); + $this->slugs->expects(self::once()) + ->method('isOpenBuildSchema') + ->with($entity, ApplicationVersionService::APPLICATION_SCHEMA) + ->willReturn(true); + $this->service->expects(self::once()) ->method('guardProductionVersionOwnership') ->with(applicationUuid: 'uuid-this-app', proposedVersionUuid: 'uuid-other')