From 911ac8dc46272efc77be01b613a52921043b2b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Wed, 5 Aug 2026 20:19:04 +0200 Subject: [PATCH 01/11] IBX-12204: SiteAccessService owns a scope-change stack, SiteAccessAware deprecated SiteAccessService now maintains a real LIFO stack of SiteAccess changes and exposes changeSiteAccess()/restoreSiteAccess(), wrapping the existing ScopeChangeEvent dispatch instead of requiring callers to hand-build it. getCurrent() reflects the active scope correctly, including through nested sub-requests (content preview, fragments, ESI). SiteAccessAware is marked @deprecated; straightforward "read current SiteAccess" consumers (ContentPreviewHelper, ConsoleCommandListener, HttpUtils, DefaultRouter, Generator, AliasGeneratorDecorator, DecoratedFragmentRenderer/InlineFragmentRenderer) are migrated to SiteAccessService::getCurrent() instead. --- phpstan-baseline.neon | 66 ------- .../Compiler/ChainRoutingPass.php | 4 +- .../Compiler/FragmentPass.php | 9 +- .../Compiler/SecurityPass.php | 6 +- .../EventListener/ConsoleCommandListener.php | 31 ++- .../Fragment/DecoratedFragmentRenderer.php | 19 +- .../Core/Fragment/InlineFragmentRenderer.php | 11 +- .../Imagine/Cache/AliasGeneratorDecorator.php | 29 ++- src/bundle/Core/Resources/config/helpers.yml | 6 +- src/bundle/Core/Resources/config/image.yml | 3 +- src/bundle/Core/Resources/config/routing.yml | 7 +- src/bundle/Core/Resources/config/services.yml | 6 +- src/bundle/Core/Routing/DefaultRouter.php | 16 +- .../Config/ComplexConfigProcessor.php | 15 +- src/lib/Helper/ContentPreviewHelper.php | 40 +--- .../Controller/Content/PreviewController.php | 4 + src/lib/MVC/Symfony/Routing/Generator.php | 14 +- src/lib/MVC/Symfony/Security/HttpUtils.php | 12 +- .../Symfony/SiteAccess/SiteAccessAware.php | 2 + .../Symfony/SiteAccess/SiteAccessService.php | 79 +++++++- .../SiteAccess/SiteAccessServiceInterface.php | 13 ++ .../Compiler/ChainRoutingPassTest.php | 8 +- .../Compiler/FragmentPassTest.php | 19 +- .../Compiler/SecurityPassTest.php | 8 +- .../ConsoleCommandListenerTest.php | 35 ++-- .../DecoratedFragmentRendererTest.php | 17 +- .../bundle/Core/Routing/DefaultRouterTest.php | 16 +- .../IbexaIOExtensionTest.php | 18 ++ .../Core/Resources/settings/common.yml | 3 +- tests/lib/Helper/ContentPreviewHelperTest.php | 36 ++-- .../lib/MVC/Symfony/Routing/GeneratorTest.php | 16 +- .../Symfony/Routing/UrlAliasGeneratorTest.php | 26 ++- .../MVC/Symfony/Security/HttpUtilsTest.php | 20 +- .../SiteAccess/SiteAccessServiceTest.php | 181 ++++++++++++++++-- 34 files changed, 514 insertions(+), 281 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a3ed987b63..9fa19e53e7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1608,18 +1608,6 @@ parameters: count: 1 path: src/bundle/Core/EventListener/ConfigScopeListener.php - - - message: '#^Cannot access property \$matchingType on Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' - identifier: property.nonObject - count: 2 - path: src/bundle/Core/EventListener/ConsoleCommandListener.php - - - - message: '#^Cannot access property \$name on Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' - identifier: property.nonObject - count: 3 - path: src/bundle/Core/EventListener/ConsoleCommandListener.php - - message: '#^Method Ibexa\\Bundle\\Core\\EventListener\\ConsoleCommandListener\:\:onConsoleCommand\(\) has no return type specified\.$#' identifier: missingType.return @@ -1638,18 +1626,6 @@ parameters: count: 1 path: src/bundle/Core/EventListener/ConsoleCommandListener.php - - - message: '#^Method Ibexa\\Bundle\\Core\\EventListener\\ConsoleCommandListener\:\:setSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/Core/EventListener/ConsoleCommandListener.php - - - - message: '#^Parameter \#1 \$siteAccess of class Ibexa\\Core\\MVC\\Symfony\\Event\\ScopeChangeEvent constructor expects Ibexa\\Core\\MVC\\Symfony\\SiteAccess, Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null given\.$#' - identifier: argument.type - count: 1 - path: src/bundle/Core/EventListener/ConsoleCommandListener.php - - message: '#^Method Ibexa\\Bundle\\Core\\EventListener\\ContentDownloadRouteReferenceListener\:\:configureOptions\(\) has no return type specified\.$#' identifier: missingType.return @@ -2982,12 +2958,6 @@ parameters: count: 1 path: src/bundle/Core/Fragment/InlineFragmentRenderer.php - - - message: '#^Property Ibexa\\Bundle\\Core\\Fragment\\InlineFragmentRenderer\:\:\$siteAccess is never read, only written\.$#' - identifier: property.onlyWritten - count: 1 - path: src/bundle/Core/Fragment/InlineFragmentRenderer.php - - message: '#^Trait Ibexa\\Bundle\\Core\\Fragment\\SiteAccessSerializationTrait is used zero times and is not analysed\.$#' identifier: trait.unused @@ -3036,24 +3006,6 @@ parameters: count: 1 path: src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php - - - message: '#^Method Ibexa\\Bundle\\Core\\Imagine\\Cache\\AliasGeneratorDecorator\:\:setSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php - - - - message: '#^Property Ibexa\\Bundle\\Core\\Imagine\\Cache\\AliasGeneratorDecorator\:\:\$siteAccess \(Ibexa\\Core\\MVC\\Symfony\\SiteAccess\) does not accept Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' - identifier: assign.propertyType - count: 1 - path: src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php - - - - message: '#^Property Ibexa\\Core\\MVC\\Symfony\\SiteAccess\:\:\$name \(string\) on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.property - count: 2 - path: src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php - - message: '#^Method Ibexa\\Bundle\\Core\\Imagine\\Cache\\Resolver\\RelativeResolver\:\:rewriteUrl\(\) has parameter \$url with no type specified\.$#' identifier: missingType.parameter @@ -3408,12 +3360,6 @@ parameters: count: 1 path: src/bundle/Core/Routing/DefaultRouter.php - - - message: '#^Cannot access property \$name on Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' - identifier: property.nonObject - count: 2 - path: src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php - - message: '#^Method Ibexa\\Bundle\\Core\\SiteAccess\\Matcher\:\:setMatchingConfiguration\(\) has no return type specified\.$#' identifier: missingType.return @@ -7362,18 +7308,6 @@ parameters: count: 1 path: src/lib/Helper/ContentPreviewHelper.php - - - message: '#^Method Ibexa\\Core\\Helper\\ContentPreviewHelper\:\:setSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/lib/Helper/ContentPreviewHelper.php - - - - message: '#^Property Ibexa\\Core\\Helper\\ContentPreviewHelper\:\:\$originalSiteAccess \(Ibexa\\Core\\MVC\\Symfony\\SiteAccess\) does not accept Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' - identifier: assign.propertyType - count: 1 - path: src/lib/Helper/ContentPreviewHelper.php - - message: '#^Cannot access property \$fieldTypeIdentifier on Ibexa\\Contracts\\Core\\Repository\\Values\\ContentType\\FieldDefinition\|null\.$#' identifier: property.nonObject diff --git a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php index 76d9769a08..30df618e99 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php @@ -8,8 +8,8 @@ namespace Ibexa\Bundle\Core\DependencyInjection\Compiler; use Ibexa\Core\MVC\Symfony\Routing\ChainRouter; -use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\Router; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -28,7 +28,7 @@ public function process(ContainerBuilder $container): void // The default router will be given the highest priority so that it will be used by default if ($container->hasDefinition('router.default')) { $defaultRouter = $container->getDefinition('router.default'); - $defaultRouter->addMethodCall('setSiteAccess', [new Reference(SiteAccess::class)]); + $defaultRouter->addMethodCall('setSiteAccessService', [new Reference(SiteAccessServiceInterface::class)]); $defaultRouter->addMethodCall('setConfigResolver', [new Reference('ibexa.config.resolver')]); $defaultRouter->addMethodCall( 'setNonSiteAccessAwareRoutes', diff --git a/src/bundle/Core/DependencyInjection/Compiler/FragmentPass.php b/src/bundle/Core/DependencyInjection/Compiler/FragmentPass.php index 2ddea81531..dbb86c6478 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/FragmentPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/FragmentPass.php @@ -11,6 +11,7 @@ use Ibexa\Bundle\Core\Fragment\FragmentListenerFactory; use Ibexa\Bundle\Core\Fragment\InlineFragmentRenderer; use Ibexa\Bundle\Core\Fragment\SiteAccessSerializerInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -49,10 +50,16 @@ public function process(ContainerBuilder $container): void $container->setDefinition($renamedId, $definition); $decoratedDef = new ChildDefinition(DecoratedFragmentRenderer::class); - $decoratedDef->setArguments([new Reference($renamedId), new Reference(SiteAccessSerializerInterface::class)]); + $decoratedDef->setArguments([ + new Reference($renamedId), + new Reference(SiteAccessSerializerInterface::class), + new Reference(SiteAccessServiceInterface::class), + ]); $decoratedDef->setPublic($public); $decoratedDef->setTags($tags); // Special treatment for inline fragment renderer, to fit ESI renderer constructor type hinting (forced to InlineFragmentRenderer) + // Note: InlineFragmentRenderer's constructor only declares the first 2 arguments (it has + // no use for SiteAccessServiceInterface); PHP silently ignores the extra 3rd argument. if ($id === 'fragment.renderer.inline') { $decoratedDef->setClass(InlineFragmentRenderer::class); } diff --git a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php index 43e5d14dc5..3271e1637e 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php @@ -12,7 +12,7 @@ use Ibexa\Core\MVC\Symfony\Security\Authentication\AnonymousUserAccessListener; use Ibexa\Core\MVC\Symfony\Security\Authentication\DefaultAuthenticationSuccessHandler; use Ibexa\Core\MVC\Symfony\Security\HttpUtils; -use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\OutOfBoundsException; @@ -40,8 +40,8 @@ private function registerHttpUtils(ContainerBuilder $container): void $httpUtilsDef = $container->findDefinition('security.http_utils'); $httpUtilsDef->setClass(HttpUtils::class); $httpUtilsDef->addMethodCall( - 'setSiteAccess', - [new Reference(SiteAccess::class)] + 'setSiteAccessService', + [new Reference(SiteAccessServiceInterface::class)] ); } diff --git a/src/bundle/Core/EventListener/ConsoleCommandListener.php b/src/bundle/Core/EventListener/ConsoleCommandListener.php index ab882de5c5..ed621212f4 100644 --- a/src/bundle/Core/EventListener/ConsoleCommandListener.php +++ b/src/bundle/Core/EventListener/ConsoleCommandListener.php @@ -8,16 +8,13 @@ namespace Ibexa\Bundle\Core\EventListener; use Ibexa\Core\MVC\Exception\InvalidSiteAccessException; -use Ibexa\Core\MVC\Symfony\Event\ScopeChangeEvent; -use Ibexa\Core\MVC\Symfony\MVCEvents; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -class ConsoleCommandListener implements EventSubscriberInterface, SiteAccessAware +class ConsoleCommandListener implements EventSubscriberInterface { /** @var string */ private $defaultSiteAccessName; @@ -25,24 +22,25 @@ class ConsoleCommandListener implements EventSubscriberInterface, SiteAccessAwar /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessProviderInterface */ private $siteAccessProvider; - /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */ - private $eventDispatcher; - - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess|null */ + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ private $siteAccess; + private SiteAccessServiceInterface $siteAccessService; + /** @var bool */ private $debug; public function __construct( string $defaultSiteAccessName, SiteAccess\SiteAccessProviderInterface $siteAccessProvider, - EventDispatcherInterface $eventDispatcher, + SiteAccess $siteAccess, + SiteAccessServiceInterface $siteAccessService, bool $debug = false ) { $this->defaultSiteAccessName = $defaultSiteAccessName; $this->siteAccessProvider = $siteAccessProvider; - $this->eventDispatcher = $eventDispatcher; + $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; $this->debug = $debug; } @@ -57,6 +55,10 @@ public static function getSubscribedEvents(): array public function onConsoleCommand(ConsoleCommandEvent $event) { + // Note: this mutates the shared SiteAccess singleton in place (rather than only calling + // changeSiteAccess() below), because consumers that still read that singleton directly + // (e.g. the config resolver chain, for its MATCHING_TYPE_UNINITIALIZED early-access guard) + // never receive a SiteAccess through SiteAccessService and would otherwise never see it change. $this->siteAccess->name = $event->getInput()->getParameterOption('--siteaccess', $this->defaultSiteAccessName); $this->siteAccess->matchingType = 'cli'; @@ -69,12 +71,7 @@ public function onConsoleCommand(ConsoleCommandEvent $event) ); } - $this->eventDispatcher->dispatch(new ScopeChangeEvent($this->siteAccess), MVCEvents::CONFIG_SCOPE_CHANGE); - } - - public function setSiteAccess(?SiteAccess $siteAccess = null) - { - $this->siteAccess = $siteAccess; + $this->siteAccessService->changeSiteAccess($this->siteAccess); } public function setDebug($debug = false) diff --git a/src/bundle/Core/Fragment/DecoratedFragmentRenderer.php b/src/bundle/Core/Fragment/DecoratedFragmentRenderer.php index b13bc5d6c3..2f0e68d37e 100644 --- a/src/bundle/Core/Fragment/DecoratedFragmentRenderer.php +++ b/src/bundle/Core/Fragment/DecoratedFragmentRenderer.php @@ -8,32 +8,29 @@ namespace Ibexa\Bundle\Core\Fragment; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Controller\ControllerReference; use Symfony\Component\HttpKernel\Fragment\FragmentRendererInterface; use Symfony\Component\HttpKernel\Fragment\RoutableFragmentRenderer; -class DecoratedFragmentRenderer implements FragmentRendererInterface, SiteAccessAware +class DecoratedFragmentRenderer implements FragmentRendererInterface { private FragmentRendererInterface $innerRenderer; - private ?SiteAccess $siteAccess = null; - private SiteAccessSerializerInterface $siteAccessSerializer; + private SiteAccessServiceInterface $siteAccessService; + public function __construct( FragmentRendererInterface $innerRenderer, - SiteAccessSerializerInterface $siteAccessSerializer + SiteAccessSerializerInterface $siteAccessSerializer, + SiteAccessServiceInterface $siteAccessService ) { $this->innerRenderer = $innerRenderer; $this->siteAccessSerializer = $siteAccessSerializer; - } - - public function setSiteAccess(?SiteAccess $siteAccess = null): void - { - $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; } public function setFragmentPath(string $path): void @@ -42,7 +39,7 @@ public function setFragmentPath(string $path): void return; } - $matcher = $this->siteAccess?->matcher; + $matcher = $this->siteAccessService->getCurrent()?->matcher; if ($matcher instanceof SiteAccess\URILexer) { $path = $matcher->analyseLink($path); } diff --git a/src/bundle/Core/Fragment/InlineFragmentRenderer.php b/src/bundle/Core/Fragment/InlineFragmentRenderer.php index cb7f348f2f..3607a53984 100644 --- a/src/bundle/Core/Fragment/InlineFragmentRenderer.php +++ b/src/bundle/Core/Fragment/InlineFragmentRenderer.php @@ -7,8 +7,6 @@ namespace Ibexa\Bundle\Core\Fragment; -use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Controller\ControllerReference; @@ -16,12 +14,10 @@ use Symfony\Component\HttpKernel\Fragment\InlineFragmentRenderer as BaseRenderer; use Symfony\Component\HttpKernel\Fragment\RoutableFragmentRenderer; -class InlineFragmentRenderer extends BaseRenderer implements SiteAccessAware, FragmentRendererInterface +class InlineFragmentRenderer extends BaseRenderer implements FragmentRendererInterface { protected FragmentRendererInterface $innerRenderer; - private ?SiteAccess $siteAccess = null; - private SiteAccessSerializerInterface $siteAccessSerializer; public function __construct( @@ -39,11 +35,6 @@ public function setFragmentPath($path): void } } - public function setSiteAccess(?SiteAccess $siteAccess = null): void - { - $this->siteAccess = $siteAccess; - } - /** * @param array $options */ diff --git a/src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php b/src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php index 3416d2068f..03ec2447b6 100644 --- a/src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php +++ b/src/bundle/Core/Imagine/Cache/AliasGeneratorDecorator.php @@ -11,8 +11,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\VersionInfo; use Ibexa\Contracts\Core\Variation\Values\Variation; use Ibexa\Contracts\Core\Variation\VariationHandler; -use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\Persistence\Cache\Identifier\CacheIdentifierGeneratorInterface; use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; use Symfony\Component\Routing\RequestContext; @@ -20,7 +19,7 @@ /** * Persistence Cache layer for AliasGenerator. */ -class AliasGeneratorDecorator implements VariationHandler, SiteAccessAware +class AliasGeneratorDecorator implements VariationHandler { private const IMAGE_VARIATION_IDENTIFIER = 'image_variation'; private const IMAGE_VARIATION_SITEACCESS_IDENTIFIER = 'image_variation_siteaccess'; @@ -36,15 +35,14 @@ class AliasGeneratorDecorator implements VariationHandler, SiteAccessAware /** @var \Symfony\Component\Cache\Adapter\TagAwareAdapterInterface */ private $cache; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - private $siteAccess; - /** @var \Symfony\Component\Routing\RequestContext */ private $requestContext; /** @var \Ibexa\Core\Persistence\Cache\Identifier\CacheIdentifierGeneratorInterface */ private $cacheIdentifierGenerator; + private SiteAccessServiceInterface $siteAccessService; + /** * @param \Ibexa\Contracts\Core\Variation\VariationHandler $aliasGenerator * @param \Symfony\Component\Cache\Adapter\TagAwareAdapterInterface $cache @@ -55,12 +53,14 @@ public function __construct( VariationHandler $aliasGenerator, TagAwareAdapterInterface $cache, RequestContext $requestContext, - CacheIdentifierGeneratorInterface $cacheIdentifierGenerator + CacheIdentifierGeneratorInterface $cacheIdentifierGenerator, + SiteAccessServiceInterface $siteAccessService ) { $this->aliasGenerator = $aliasGenerator; $this->cache = $cache; $this->requestContext = $requestContext; $this->cacheIdentifierGenerator = $cacheIdentifierGenerator; + $this->siteAccessService = $siteAccessService; } /** @@ -87,14 +87,6 @@ public function getVariation(Field $field, VersionInfo $versionInfo, string $var return $image; } - /** - * @param \Ibexa\Core\MVC\Symfony\SiteAccess|null $siteAccess - */ - public function setSiteAccess(?SiteAccess $siteAccess = null) - { - $this->siteAccess = $siteAccess; - } - /** * @param \Ibexa\Contracts\Core\Repository\Values\Content\Field $field * @param \Ibexa\Contracts\Core\Repository\Values\Content\VersionInfo $versionInfo @@ -104,9 +96,11 @@ public function setSiteAccess(?SiteAccess $siteAccess = null) */ private function getCacheKey(Field $field, VersionInfo $versionInfo, $variationName): string { + $siteAccess = $this->siteAccessService->getCurrent(); + return sprintf( $this->cacheIdentifierGenerator->generateKey(self::IMAGE_VARIATION_IDENTIFIER, [], true) . '-%s-%s-%s-%d-%d-%d-%s-%s', - $this->siteAccess->name ?? 'default', + $siteAccess !== null ? $siteAccess->name : 'default', $this->requestContext->getScheme(), $this->requestContext->getHost(), $this->requestContext->getScheme() === 'https' ? $this->requestContext->getHttpsPort() : $this->requestContext->getHttpPort(), @@ -120,11 +114,12 @@ private function getCacheKey(Field $field, VersionInfo $versionInfo, $variationN private function getTagsForVariation(Field $field, VersionInfo $versionInfo, string $variationName): array { $contentId = $versionInfo->getContentInfo()->id; + $siteAccess = $this->siteAccessService->getCurrent(); return [ $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_IDENTIFIER), $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_NAME_IDENTIFIER, [$variationName]), - $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_SITEACCESS_IDENTIFIER, [$this->siteAccess->name ?? 'default']), + $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_SITEACCESS_IDENTIFIER, [$siteAccess !== null ? $siteAccess->name : 'default']), $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_CONTENT_IDENTIFIER, [$contentId]), $this->cacheIdentifierGenerator->generateTag(self::IMAGE_VARIATION_FIELD_IDENTIFIER, [$field->id]), $this->cacheIdentifierGenerator->generateTag(self::CONTENT_IDENTIFIER, [$contentId]), diff --git a/src/bundle/Core/Resources/config/helpers.yml b/src/bundle/Core/Resources/config/helpers.yml index ceb16c89cf..0888a9b550 100644 --- a/src/bundle/Core/Resources/config/helpers.yml +++ b/src/bundle/Core/Resources/config/helpers.yml @@ -16,9 +16,9 @@ services: arguments: ['@Ibexa\Core\Helper\TranslationHelper', '@ibexa.api.service.field_type'] Ibexa\Core\Helper\ContentPreviewHelper: - arguments: ["@event_dispatcher", '@Ibexa\Core\MVC\Symfony\SiteAccess\Router'] - calls: - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] + arguments: + - '@Ibexa\Core\MVC\Symfony\SiteAccess\Router' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' Ibexa\Bundle\Core\EventListener\ConfigScopeListener: arguments: diff --git a/src/bundle/Core/Resources/config/image.yml b/src/bundle/Core/Resources/config/image.yml index d3a1f4fe91..3609cf381a 100644 --- a/src/bundle/Core/Resources/config/image.yml +++ b/src/bundle/Core/Resources/config/image.yml @@ -68,8 +68,7 @@ services: - '@ibexa.cache_pool' - '@router.request_context' - '@Ibexa\Core\Persistence\Cache\Identifier\CacheIdentifierGeneratorInterface' - calls: - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' ibexa.image_alias.imagine.alias_generator: class: Ibexa\Bundle\Core\Imagine\AliasGenerator diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index e74fe3208d..b326768cf2 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -31,7 +31,7 @@ services: abstract: true calls: - [setRequestContext, ["@router.request_context"]] - - [setSiteAccess, ['@?Ibexa\Core\MVC\Symfony\SiteAccess']] + - [setSiteAccessService, ['@?Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService']] - [setSiteAccessRouter, ['@Ibexa\Core\MVC\Symfony\SiteAccess\Router']] - [setLogger, ["@?logger"]] @@ -88,8 +88,9 @@ services: arguments: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - calls: - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] + - '@event_dispatcher' + tags: + - { name: kernel.event_subscriber } Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface: alias: 'Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 129414be41..479a6cae47 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -74,10 +74,9 @@ services: arguments: - '%ibexa.site_access.default%' - '@ibexa.siteaccess.provider' - - "@event_dispatcher" + - '@Ibexa\Core\MVC\Symfony\SiteAccess' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' - "%kernel.debug%" - calls: - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] tags: - { name: kernel.event_subscriber } @@ -165,7 +164,6 @@ services: arguments: [] calls: - [setFragmentPath, ["%fragment.path%"]] - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] abstract: true Ibexa\Bundle\Core\Fragment\DirectFragmentRenderer: diff --git a/src/bundle/Core/Routing/DefaultRouter.php b/src/bundle/Core/Routing/DefaultRouter.php index b8999d2460..9f91127ebc 100644 --- a/src/bundle/Core/Routing/DefaultRouter.php +++ b/src/bundle/Core/Routing/DefaultRouter.php @@ -11,8 +11,8 @@ use Ibexa\Core\MVC\Symfony\Routing\RequestContextFactory; use Ibexa\Core\MVC\Symfony\Routing\SimplifiedRequest; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\MVC\Symfony\SiteAccess\URILexer; use Symfony\Bundle\FrameworkBundle\Routing\Router; use Symfony\Component\HttpFoundation\Request; @@ -22,10 +22,8 @@ /** * Extension of Symfony default router implementing RequestMatcherInterface. */ -class DefaultRouter extends Router implements SiteAccessAware +class DefaultRouter extends Router { - protected ?SiteAccess $siteAccess = null; - /** @var string[] */ protected array $nonSiteAccessAwareRoutes = []; @@ -33,14 +31,16 @@ class DefaultRouter extends Router implements SiteAccessAware protected SiteAccessRouterInterface $siteAccessRouter; + private ?SiteAccessServiceInterface $siteAccessService = null; + public function setConfigResolver(ConfigResolverInterface $configResolver): void { $this->configResolver = $configResolver; } - public function setSiteAccess(?SiteAccess $siteAccess = null): void + public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void { - $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; } /** @@ -80,7 +80,7 @@ public function matchRequest(Request $request): array */ public function generate(string $name, array $parameters = [], int $referenceType = self::ABSOLUTE_PATH): string { - $siteAccess = $this->siteAccess; + $siteAccess = $this->siteAccessService?->getCurrent(); $originalContext = $context = $this->getContext(); $isSiteAccessAware = $this->isSiteAccessAwareRoute($name); @@ -92,7 +92,7 @@ public function generate(string $name, array $parameters = [], int $referenceTyp $context = $this->getContextBySimplifiedRequest($siteAccess->matcher->getRequest()); $this->setContext($context); } elseif ($this->logger) { - $siteAccess = $this->siteAccess; + $siteAccess = $this->siteAccessService?->getCurrent(); $this->logger->notice("Could not generate a link using provided 'siteaccess' parameter: {$parameters['siteaccess']}. Generating using current context."); } diff --git a/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php b/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php index 5329e4dffc..f9279e167e 100644 --- a/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php +++ b/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php @@ -11,6 +11,7 @@ use Ibexa\Bundle\Core\DependencyInjection\Configuration\ComplexSettings\ComplexSettingParser; use Ibexa\Contracts\Core\SiteAccess\ConfigProcessor; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; +use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\MVC\Exception\ParameterNotFoundException; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; use function str_replace; @@ -41,7 +42,7 @@ public function __construct( public function processComplexSetting(string $setting): string { - $siteAccessName = $this->siteAccessService->getCurrent()->name; + $siteAccessName = $this->getCurrentSiteAccessName(); if (!$this->configResolver->hasParameter($setting, null, $siteAccessName)) { throw new ParameterNotFoundException($setting, null, [$siteAccessName]); @@ -74,7 +75,7 @@ public function processSettingValue(string $value): string $dynamicSettingValue = $this->configResolver->getParameter( $parts['param'], $parts['namespace'], - $parts['scope'] ?? $this->siteAccessService->getCurrent()->name + $parts['scope'] ?? $this->getCurrentSiteAccessName() ); $value = str_replace($dynamicSetting, $dynamicSettingValue, $value); @@ -82,4 +83,14 @@ public function processSettingValue(string $value): string return $value; } + + private function getCurrentSiteAccessName(): string + { + $siteAccess = $this->siteAccessService->getCurrent(); + if ($siteAccess === null) { + throw new InvalidArgumentException('siteAccess', 'no SiteAccess given and none currently set'); + } + + return $siteAccess->name; + } } diff --git a/src/lib/Helper/ContentPreviewHelper.php b/src/lib/Helper/ContentPreviewHelper.php index ed375dd2a7..af92128409 100644 --- a/src/lib/Helper/ContentPreviewHelper.php +++ b/src/lib/Helper/ContentPreviewHelper.php @@ -9,23 +9,15 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Content; use Ibexa\Contracts\Core\Repository\Values\Content\Location; -use Ibexa\Core\MVC\Symfony\Event\ScopeChangeEvent; -use Ibexa\Core\MVC\Symfony\MVCEvents; -use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; -class ContentPreviewHelper implements SiteAccessAware +class ContentPreviewHelper { - /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface */ - protected $eventDispatcher; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface */ protected $siteAccessRouter; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - protected $originalSiteAccess; + private SiteAccessServiceInterface $siteAccessService; /** @var bool */ private $previewActive = false; @@ -36,25 +28,20 @@ class ContentPreviewHelper implements SiteAccessAware /** @var \Ibexa\Contracts\Core\Repository\Values\Content\Location */ private $previewedLocation; - public function __construct(EventDispatcherInterface $eventDispatcher, SiteAccessRouterInterface $siteAccessRouter) + public function __construct(SiteAccessRouterInterface $siteAccessRouter, SiteAccessServiceInterface $siteAccessService) { - $this->eventDispatcher = $eventDispatcher; $this->siteAccessRouter = $siteAccessRouter; - } - - public function setSiteAccess(?SiteAccess $siteAccess = null) - { - $this->originalSiteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; } /** * Return original SiteAccess. * - * @return \Ibexa\Core\MVC\Symfony\SiteAccess + * @return \Ibexa\Core\MVC\Symfony\SiteAccess|null */ public function getOriginalSiteAccess() { - return $this->originalSiteAccess; + return $this->siteAccessService->getCurrent(); } /** @@ -66,23 +53,17 @@ public function getOriginalSiteAccess() */ public function changeConfigScope($siteAccessName) { - $event = new ScopeChangeEvent($this->siteAccessRouter->matchByName($siteAccessName)); - $this->eventDispatcher->dispatch($event, MVCEvents::CONFIG_SCOPE_CHANGE); - - return $event->getSiteAccess(); + return $this->siteAccessService->changeSiteAccess($this->siteAccessRouter->matchByName($siteAccessName)); } /** * Restores original config scope. * - * @return \Ibexa\Core\MVC\Symfony\SiteAccess + * @return \Ibexa\Core\MVC\Symfony\SiteAccess|null */ public function restoreConfigScope() { - $event = new ScopeChangeEvent($this->originalSiteAccess); - $this->eventDispatcher->dispatch($event, MVCEvents::CONFIG_SCOPE_RESTORE); - - return $event->getSiteAccess(); + return $this->siteAccessService->restoreSiteAccess(); } /** @@ -99,7 +80,6 @@ public function isPreviewActive() public function setPreviewActive($previewActive) { $this->previewActive = (bool)$previewActive; - $this->originalSiteAccess = clone $this->originalSiteAccess; } /** diff --git a/src/lib/MVC/Symfony/Controller/Content/PreviewController.php b/src/lib/MVC/Symfony/Controller/Content/PreviewController.php index 5ce1da3e2a..aa464234ca 100644 --- a/src/lib/MVC/Symfony/Controller/Content/PreviewController.php +++ b/src/lib/MVC/Symfony/Controller/Content/PreviewController.php @@ -121,6 +121,10 @@ public function previewContentAction( } $siteAccess = $this->previewHelper->getOriginalSiteAccess(); + if ($siteAccess === null) { + throw new BadStateException('siteAccess', 'no SiteAccess currently set, cannot preview'); + } + // Only switch if $siteAccessName is set and different from original if ($siteAccessName !== null && $siteAccessName !== $siteAccess->name) { $siteAccess = $this->previewHelper->changeConfigScope($siteAccessName); diff --git a/src/lib/MVC/Symfony/Routing/Generator.php b/src/lib/MVC/Symfony/Routing/Generator.php index 9729d0b90a..b58d64ba0a 100644 --- a/src/lib/MVC/Symfony/Routing/Generator.php +++ b/src/lib/MVC/Symfony/Routing/Generator.php @@ -8,8 +8,8 @@ namespace Ibexa\Core\MVC\Symfony\Routing; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RequestContext; @@ -17,13 +17,13 @@ /** * Base class for Ibexa Url generation. */ -abstract class Generator implements SiteAccessAware +abstract class Generator { protected RequestContext $requestContext; protected SiteAccessRouterInterface $siteAccessRouter; - protected ?SiteAccess $siteAccess; + private ?SiteAccessServiceInterface $siteAccessService = null; protected ?LoggerInterface $logger; @@ -37,9 +37,9 @@ public function setSiteAccessRouter(SiteAccessRouterInterface $siteAccessRouter) $this->siteAccessRouter = $siteAccessRouter; } - public function setSiteAccess(?SiteAccess $siteAccess = null): void + public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void { - $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; } public function setLogger(?LoggerInterface $logger = null): void @@ -57,7 +57,7 @@ public function setLogger(?LoggerInterface $logger = null): void */ public function generate(mixed $urlResource, array $parameters, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string { - $siteAccess = $this->siteAccess; + $siteAccess = $this->siteAccessService?->getCurrent(); $requestContext = $this->requestContext; // Retrieving the appropriate SiteAccess to generate the link for. @@ -69,7 +69,7 @@ public function generate(mixed $urlResource, array $parameters, int $referenceTy $siteAccess->matcher->getRequest() ); } elseif (isset($this->logger)) { - $siteAccess = $this->siteAccess; + $siteAccess = $this->siteAccessService?->getCurrent(); $this->logger->notice("Could not generate a link using provided 'siteaccess' parameter: {$parameters['siteaccess']}. Generating using current context."); unset($parameters['siteaccess']); } diff --git a/src/lib/MVC/Symfony/Security/HttpUtils.php b/src/lib/MVC/Symfony/Security/HttpUtils.php index 4e1e8c725b..0f4125d8ff 100644 --- a/src/lib/MVC/Symfony/Security/HttpUtils.php +++ b/src/lib/MVC/Symfony/Security/HttpUtils.php @@ -8,22 +8,22 @@ namespace Ibexa\Core\MVC\Symfony\Security; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Http\HttpUtils as BaseHttpUtils; -class HttpUtils extends BaseHttpUtils implements SiteAccessAware +class HttpUtils extends BaseHttpUtils { - private ?SiteAccess $siteAccess; + private ?SiteAccessServiceInterface $siteAccessService = null; - public function setSiteAccess(?SiteAccess $siteAccess = null): void + public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void { - $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; } private function analyzeLink(string $path): string { - $matcher = $this->siteAccess?->matcher; + $matcher = $this->siteAccessService?->getCurrent()?->matcher; if ($path[0] === '/' && $matcher instanceof SiteAccess\URILexer) { $path = $matcher->analyseLink($path); } diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessAware.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessAware.php index d3f09d2a6f..ae8c9549bf 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessAware.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessAware.php @@ -11,6 +11,8 @@ /** * Interface for SiteAccess aware services. + * + * @deprecated 6.0, to be removed in 7.0. Use \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface::getCurrent() instead. */ interface SiteAccessAware { diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php index 0676707544..1cab85b62a 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php @@ -11,22 +11,56 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; +use Ibexa\Core\MVC\Symfony\Event\PostSiteAccessMatchEvent; +use Ibexa\Core\MVC\Symfony\Event\ScopeChangeEvent; +use Ibexa\Core\MVC\Symfony\MVCEvents; use Ibexa\Core\MVC\Symfony\SiteAccess; use function iterator_to_array; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; -class SiteAccessService implements SiteAccessServiceInterface, SiteAccessAware +class SiteAccessService implements SiteAccessServiceInterface, EventSubscriberInterface { - private ?SiteAccess $siteAccess = null; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess[] */ + private array $siteAccessStack = []; public function __construct( private readonly SiteAccessProviderInterface $provider, - private readonly ConfigResolverInterface $configResolver + private readonly ConfigResolverInterface $configResolver, + private readonly EventDispatcherInterface $eventDispatcher ) { } - public function setSiteAccess(?SiteAccess $siteAccess = null): void + public static function getSubscribedEvents(): array { - $this->siteAccess = $siteAccess; + return [ + MVCEvents::SITEACCESS => 'onSiteAccessMatch', + KernelEvents::FINISH_REQUEST => 'onKernelFinishRequest', + ]; + } + + /** + * Establishes the base of the SiteAccess stack for a top-level request, or pushes an + * additional entry for a sub-request (e.g. content preview, fragments, ESI). + */ + public function onSiteAccessMatch(PostSiteAccessMatchEvent $event): void + { + if ($event->getRequestType() === HttpKernelInterface::MAIN_REQUEST) { + $this->siteAccessStack = [$event->getSiteAccess()]; + } else { + $this->siteAccessStack[] = $event->getSiteAccess(); + } + } + + /** + * Undoes the push performed by onSiteAccessMatch() for the request/sub-request that just finished. + */ + public function onKernelFinishRequest(FinishRequestEvent $event): void + { + $this->popSiteAccessStack(); } public function exists(string $name): bool @@ -50,12 +84,31 @@ public function getAll(): iterable public function getCurrent(): ?SiteAccess { - return $this->siteAccess ?? null; + return $this->siteAccessStack !== [] ? end($this->siteAccessStack) : null; + } + + public function changeSiteAccess(SiteAccess $siteAccess): SiteAccess + { + $this->siteAccessStack[] = $siteAccess; + $this->eventDispatcher->dispatch(new ScopeChangeEvent($siteAccess), MVCEvents::CONFIG_SCOPE_CHANGE); + + return $siteAccess; + } + + public function restoreSiteAccess(): ?SiteAccess + { + $this->popSiteAccessStack(); + $siteAccess = $this->getCurrent(); + if ($siteAccess !== null) { + $this->eventDispatcher->dispatch(new ScopeChangeEvent($siteAccess), MVCEvents::CONFIG_SCOPE_RESTORE); + } + + return $siteAccess; } public function getSiteAccessesRelation(?SiteAccess $siteAccess = null): array { - $siteAccess = $siteAccess ?? $this->siteAccess; + $siteAccess ??= $this->getCurrent(); if ($siteAccess === null) { throw new InvalidArgumentException('siteAccess', 'no SiteAccess given and none currently set'); } @@ -87,4 +140,16 @@ public function getSiteAccessesRelation(?SiteAccess $siteAccess = null): array return $saRelationMap[$repository][$rootLocationId]; } + + /** + * Pops the SiteAccess stack, but never below one remaining entry: the bottom-most entry + * (the current top-level request's SiteAccess, or the CLI SiteAccess established via + * changeSiteAccess()) must survive an unbalanced restore or an unrelated sub-request finishing. + */ + private function popSiteAccessStack(): void + { + if (count($this->siteAccessStack) > 1) { + array_pop($this->siteAccessStack); + } + } } diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessServiceInterface.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessServiceInterface.php index b95cfda289..feb664114e 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessServiceInterface.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessServiceInterface.php @@ -37,4 +37,17 @@ public function getCurrent(): ?SiteAccess; * @throws \Ibexa\Core\Base\Exceptions\InvalidArgumentException if no SiteAccess is given and none is currently set */ public function getSiteAccessesRelation(?SiteAccess $siteAccess = null): array; + + /** + * Switches the current SiteAccess to $siteAccess, remembering the previous one so it can be + * restored later via restoreSiteAccess(). Dispatches a ScopeChangeEvent under + * MVCEvents::CONFIG_SCOPE_CHANGE. + */ + public function changeSiteAccess(SiteAccess $siteAccess): SiteAccess; + + /** + * Restores the SiteAccess that was current before the last changeSiteAccess() call. + * Dispatches a ScopeChangeEvent under MVCEvents::CONFIG_SCOPE_RESTORE. + */ + public function restoreSiteAccess(): ?SiteAccess; } diff --git a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php index a97122ffd3..2f0ac07e96 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php @@ -9,8 +9,8 @@ use Ibexa\Bundle\Core\DependencyInjection\Compiler\ChainRoutingPass; use Ibexa\Core\MVC\Symfony\Routing\ChainRouter; -use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\Router; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -74,7 +74,7 @@ public function testAddRouterWithDefaultRouter($declaredPriority, $expectedPrior { $defaultRouter = new Definition(); $this->setDefinition('router.default', $defaultRouter); - $this->setDefinition(SiteAccess::class, new Definition()); + $this->setDefinition(SiteAccessServiceInterface::class, new Definition()); $this->setDefinition('ibexa.config.resolver', new Definition()); $this->setDefinition(Router::class, new Definition()); @@ -92,8 +92,8 @@ public function testAddRouterWithDefaultRouter($declaredPriority, $expectedPrior // Assertion for default router $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( 'router.default', - 'setSiteAccess', - [new Reference(SiteAccess::class)] + 'setSiteAccessService', + [new Reference(SiteAccessServiceInterface::class)] ); $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( 'router.default', diff --git a/tests/bundle/Core/DependencyInjection/Compiler/FragmentPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/FragmentPassTest.php index 6616520948..2dc18c5a98 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/FragmentPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/FragmentPassTest.php @@ -12,6 +12,7 @@ use Ibexa\Bundle\Core\Fragment\FragmentListenerFactory; use Ibexa\Bundle\Core\Fragment\InlineFragmentRenderer; use Ibexa\Bundle\Core\Fragment\SiteAccessSerializerInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -67,7 +68,11 @@ public function testProcess() $decoratedInlineDef = $this->container->getDefinition('fragment.renderer.inline'); self::assertSame(['kernel.fragment_renderer' => [[]]], $decoratedInlineDef->getTags()); self::assertEquals( - [new Reference('fragment.renderer.inline.inner'), new Reference(SiteAccessSerializerInterface::class)], + [ + new Reference('fragment.renderer.inline.inner'), + new Reference(SiteAccessSerializerInterface::class), + new Reference(SiteAccessServiceInterface::class), + ], $decoratedInlineDef->getArguments() ); self::assertSame(InlineFragmentRenderer::class, $decoratedInlineDef->getClass()); @@ -76,7 +81,11 @@ public function testProcess() $decoratedEsiDef = $this->container->getDefinition('fragment.renderer.esi'); self::assertSame(['kernel.fragment_renderer' => [[]]], $decoratedEsiDef->getTags()); self::assertEquals( - [new Reference('fragment.renderer.esi.inner'), new Reference(SiteAccessSerializerInterface::class)], + [ + new Reference('fragment.renderer.esi.inner'), + new Reference(SiteAccessSerializerInterface::class), + new Reference(SiteAccessServiceInterface::class), + ], $decoratedEsiDef->getArguments() ); @@ -84,7 +93,11 @@ public function testProcess() $decoratedHincludeDef = $this->container->getDefinition('fragment.renderer.hinclude'); self::assertSame(['kernel.fragment_renderer' => [[]]], $decoratedHincludeDef->getTags()); self::assertEquals( - [new Reference('fragment.renderer.hinclude.inner'), new Reference(SiteAccessSerializerInterface::class)], + [ + new Reference('fragment.renderer.hinclude.inner'), + new Reference(SiteAccessSerializerInterface::class), + new Reference(SiteAccessServiceInterface::class), + ], $decoratedHincludeDef->getArguments() ); } diff --git a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php index ea08ce8da5..f47eda8ac0 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php @@ -13,7 +13,7 @@ use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\MVC\Symfony\Security\Authentication\AnonymousUserAccessListener; -use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -29,7 +29,7 @@ protected function setUp(): void $this->setDefinition('security.http_utils', new Definition()); $this->setDefinition('security.authentication.success_handler', new Definition()); $this->setDefinition(ConfigResolverInterface::class, new Definition()); - $this->setDefinition(SiteAccess::class, new Definition()); + $this->setDefinition(SiteAccessServiceInterface::class, new Definition()); $this->setDefinition(PermissionResolver::class, new Definition()); $this->setDefinition(UserService::class, new Definition()); } @@ -45,8 +45,8 @@ public function testAlteredHttpUtils(): void $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( 'security.http_utils', - 'setSiteAccess', - [new Reference(SiteAccess::class)] + 'setSiteAccessService', + [new Reference(SiteAccessServiceInterface::class)] ); } diff --git a/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php b/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php index 511a6547db..f935db0537 100644 --- a/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php +++ b/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php @@ -10,6 +10,7 @@ use Ibexa\Bundle\Core\EventListener\ConsoleCommandListener; use Ibexa\Core\MVC\Exception\InvalidSiteAccessException; use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Tests\Bundle\Core\EventListener\Stubs\TestOutput; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; @@ -19,7 +20,6 @@ use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\Output; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ConsoleCommandListenerTest extends TestCase { @@ -28,8 +28,8 @@ class ConsoleCommandListenerTest extends TestCase /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ private $siteAccess; - /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface|\PHPUnit\Framework\MockObject\MockObject */ - private $dispatcher; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $siteAccessService; /** @var \Ibexa\Bundle\Core\EventListener\ConsoleCommandListener */ private $listener; @@ -47,10 +47,13 @@ protected function setUp(): void { parent::setUp(); $this->siteAccess = new SiteAccess('test'); - $this->dispatcher = $this->createMock(EventDispatcherInterface::class); - $this->listener = new ConsoleCommandListener('default', $this->getSiteAccessProviderMock(), $this->dispatcher); - $this->listener->setSiteAccess($this->siteAccess); - $this->dispatcher->addSubscriber($this->listener); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $this->listener = new ConsoleCommandListener( + 'default', + $this->getSiteAccessProviderMock(), + $this->siteAccess, + $this->siteAccessService + ); $this->inputDefinition = new InputDefinition([new InputOption('siteaccess', null, InputOption::VALUE_OPTIONAL)]); $this->testOutput = new TestOutput(Output::VERBOSITY_QUIET, true); $this->command = $this->createMock(Command::class); @@ -71,8 +74,8 @@ public function testInvalidSiteAccessDev() $this->expectException(InvalidSiteAccessException::class); $this->expectExceptionMessageMatches('/^Invalid SiteAccess \'foo\', matched by .+\\. Valid SiteAccesses are/'); - $this->dispatcher->expects(self::never()) - ->method('dispatch'); + $this->siteAccessService->expects(self::never()) + ->method('changeSiteAccess'); $input = new ArrayInput(['--siteaccess' => 'foo'], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->setDebug(true); @@ -84,8 +87,8 @@ public function testInvalidSiteAccessProd() $this->expectException(InvalidSiteAccessException::class); $this->expectExceptionMessageMatches('/^Invalid SiteAccess \'foo\', matched by .+\\.$/'); - $this->dispatcher->expects(self::never()) - ->method('dispatch'); + $this->siteAccessService->expects(self::never()) + ->method('changeSiteAccess'); $input = new ArrayInput(['--siteaccess' => 'foo'], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->setDebug(false); @@ -94,8 +97,9 @@ public function testInvalidSiteAccessProd() public function testValidSiteAccess() { - $this->dispatcher->expects(self::once()) - ->method('dispatch'); + $this->siteAccessService->expects(self::once()) + ->method('changeSiteAccess') + ->with($this->siteAccess); $input = new ArrayInput(['--siteaccess' => 'site1'], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->onConsoleCommand($event); @@ -104,8 +108,9 @@ public function testValidSiteAccess() public function testDefaultSiteAccess() { - $this->dispatcher->expects(self::once()) - ->method('dispatch'); + $this->siteAccessService->expects(self::once()) + ->method('changeSiteAccess') + ->with($this->siteAccess); $input = new ArrayInput([], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->onConsoleCommand($event); diff --git a/tests/bundle/Core/Fragment/DecoratedFragmentRendererTest.php b/tests/bundle/Core/Fragment/DecoratedFragmentRendererTest.php index eda709b056..907cd26546 100644 --- a/tests/bundle/Core/Fragment/DecoratedFragmentRendererTest.php +++ b/tests/bundle/Core/Fragment/DecoratedFragmentRendererTest.php @@ -10,6 +10,7 @@ use Ibexa\Bundle\Core\Fragment\DecoratedFragmentRenderer; use Ibexa\Bundle\Core\Fragment\SiteAccessSerializer; use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,10 +26,13 @@ class DecoratedFragmentRendererTest extends FragmentRendererBaseTestCase { protected FragmentRendererInterface & MockObject $innerRenderer; + protected SiteAccessServiceInterface & MockObject $siteAccessService; + protected function setUp(): void { parent::setUp(); $this->innerRenderer = $this->createMock(FragmentRendererInterface::class); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); } public function testSetFragmentPathNotRoutableRenderer(): void @@ -39,8 +43,9 @@ public function testSetFragmentPathNotRoutableRenderer(): void ->expects(self::never()) ->method('analyseLink'); + $this->siteAccessService->method('getCurrent')->willReturn($siteAccess); + $renderer = $this->getRenderer(); - $renderer->setSiteAccess($siteAccess); if ($renderer instanceof RoutableFragmentRenderer) { $renderer->setFragmentPath('foo'); } @@ -56,13 +61,14 @@ public function testSetFragmentPath(): void ->with('/foo') ->willReturn('/bar/foo'); + $this->siteAccessService->method('getCurrent')->willReturn($siteAccess); + $innerRenderer = $this->createMock(RoutableFragmentRenderer::class); $innerRenderer ->expects(self::once()) ->method('setFragmentPath') ->with('/bar/foo'); - $renderer = new DecoratedFragmentRenderer($innerRenderer, new SiteAccessSerializer($this->getSerializer())); - $renderer->setSiteAccess($siteAccess); + $renderer = new DecoratedFragmentRenderer($innerRenderer, new SiteAccessSerializer($this->getSerializer()), $this->siteAccessService); $renderer->setFragmentPath('/foo'); } @@ -137,11 +143,8 @@ public function getRequest(SiteAccess $siteAccess): Request return $request; } - /** - * @return \Symfony\Component\HttpKernel\Fragment\FragmentRendererInterface&\Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware - */ public function getRenderer(): FragmentRendererInterface { - return new DecoratedFragmentRenderer($this->innerRenderer, new SiteAccessSerializer($this->getSerializer())); + return new DecoratedFragmentRenderer($this->innerRenderer, new SiteAccessSerializer($this->getSerializer()), $this->siteAccessService); } } diff --git a/tests/bundle/Core/Routing/DefaultRouterTest.php b/tests/bundle/Core/Routing/DefaultRouterTest.php index cd3a45a3a3..8d8d94a356 100644 --- a/tests/bundle/Core/Routing/DefaultRouterTest.php +++ b/tests/bundle/Core/Routing/DefaultRouterTest.php @@ -12,6 +12,7 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\MVC\Symfony\Routing\SimplifiedRequest; use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use PHPUnit\Framework\TestCase; use ReflectionObject; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -198,7 +199,7 @@ public function testGenerateWithSiteAccess($urlGenerated, $relevantUri, $expecte } $sa = new SiteAccess($saName, 'test', $matcher); - $router->setSiteAccess($sa); + $router->setSiteAccessService($this->getSiteAccessService($sa)); $requestContext = new RequestContext(); $urlComponents = parse_url($urlGenerated); @@ -274,7 +275,7 @@ public function testGenerateReverseSiteAccessMatch() $router = new DefaultRouter($this->container, 'foo', [], $this->requestContext); $router->setConfigResolver($this->configResolver); - $router->setSiteAccess(new SiteAccess('test', 'test', $this->createMock(Matcher::class))); + $router->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'test', $this->createMock(Matcher::class)))); $router->setSiteAccessRouter($siteAccessRouter); $refRouter = new ReflectionObject($router); $refGenerator = $refRouter->getProperty('generator'); @@ -342,4 +343,15 @@ private function getExpectedRequestContext($uri) return $requestContext; } + + /** + * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private function getSiteAccessService(SiteAccess $siteAccess) + { + $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $siteAccessService->method('getCurrent')->willReturn($siteAccess); + + return $siteAccessService; + } } diff --git a/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php b/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php index ea3f5356b2..0c6225ebc1 100644 --- a/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php +++ b/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php @@ -14,8 +14,14 @@ use Ibexa\Bundle\IO\DependencyInjection\ConfigurationFactory; use Ibexa\Bundle\IO\DependencyInjection\IbexaIOExtension; use Ibexa\Core\IO\UrlDecorator\AbsolutePrefix; +use Ibexa\Core\MVC\Symfony\Event\PostSiteAccessMatchEvent; +use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; use Ibexa\Tests\Integration\Core\Repository\Container\Compiler\SetAllServicesPublicPass; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Yaml\Yaml; class IbexaIOExtensionTest extends AbstractExtensionTestCase @@ -86,6 +92,15 @@ public function testUrlPrefixConfigurationIsUsedToDecorateUrl(): void ); $this->buildMinimalContainerForUrlPrefixTest(); + // Simulate SiteAccess matching having happened for the "site" siteaccess, since + // ComplexConfigProcessor now resolves the current SiteAccess via SiteAccessService + // rather than reading a pre-set property. + $siteAccessService = $this->container->get(SiteAccessService::class); + self::assertInstanceOf(SiteAccessService::class, $siteAccessService); + $siteAccessService->onSiteAccessMatch( + new PostSiteAccessMatchEvent(new SiteAccess('site'), new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + $decorator = $this->container->get(AbsolutePrefix::class); self::assertEquals( @@ -101,6 +116,9 @@ private function buildMinimalContainerForUrlPrefixTest(): void $this->container->setParameter('kernel.debug', true); $this->container->setParameter('kernel.project_dir', self::FIXTURES_DIR); $this->container->setParameter('kernel.cache_dir', self::FIXTURES_DIR . '/cache'); + // needed by Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService, pulled in transitively + // via the AliasGeneratorDecorator service definition + $this->container->register('event_dispatcher', EventDispatcher::class); $this->container->addCompilerPass(new ChainConfigResolverPass()); $this->container->addCompilerPass(new SetAllServicesPublicPass()); diff --git a/tests/integration/Core/Resources/settings/common.yml b/tests/integration/Core/Resources/settings/common.yml index 5a43ba3cdb..fc62034173 100644 --- a/tests/integration/Core/Resources/settings/common.yml +++ b/tests/integration/Core/Resources/settings/common.yml @@ -111,8 +111,7 @@ services: arguments: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - calls: - - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] + - '@Symfony\Component\EventDispatcher\EventDispatcher' Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface: alias: 'Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' diff --git a/tests/lib/Helper/ContentPreviewHelperTest.php b/tests/lib/Helper/ContentPreviewHelperTest.php index 59afe15726..e09903d446 100644 --- a/tests/lib/Helper/ContentPreviewHelperTest.php +++ b/tests/lib/Helper/ContentPreviewHelperTest.php @@ -10,17 +10,15 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Content as APIContent; use Ibexa\Contracts\Core\Repository\Values\Content\Location as APILocation; use Ibexa\Core\Helper\ContentPreviewHelper; -use Ibexa\Core\MVC\Symfony\Event\ScopeChangeEvent; -use Ibexa\Core\MVC\Symfony\MVCEvents; use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use PHPUnit\Framework\TestCase; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ContentPreviewHelperTest extends TestCase { - /** @var \PHPUnit\Framework\MockObject\MockObject */ - private $eventDispatcher; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $siteAccessService; /** @var \PHPUnit\Framework\MockObject\MockObject */ private $siteAccessRouter; @@ -31,9 +29,9 @@ class ContentPreviewHelperTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); $this->siteAccessRouter = $this->createMock(SiteAccessRouterInterface::class); - $this->previewHelper = new ContentPreviewHelper($this->eventDispatcher, $this->siteAccessRouter); + $this->previewHelper = new ContentPreviewHelper($this->siteAccessRouter, $this->siteAccessService); } public function testChangeConfigScope() @@ -47,14 +45,12 @@ public function testChangeConfigScope() ->with(self::equalTo($newSiteAccessName)) ->willReturn($newSiteAccess); - $event = new ScopeChangeEvent($newSiteAccess); - $this->eventDispatcher + $this->siteAccessService ->expects(self::once()) - ->method('dispatch') - ->with(self::equalTo($event), MVCEvents::CONFIG_SCOPE_CHANGE); + ->method('changeSiteAccess') + ->with($newSiteAccess) + ->willReturn($newSiteAccess); - $originalSiteAccess = new SiteAccess('foo', 'bar'); - $this->previewHelper->setSiteAccess($originalSiteAccess); self::assertEquals( $newSiteAccess, $this->previewHelper->changeConfigScope($newSiteAccessName) @@ -64,13 +60,11 @@ public function testChangeConfigScope() public function testRestoreConfigScope() { $originalSiteAccess = new SiteAccess('foo', 'bar'); - $event = new ScopeChangeEvent($originalSiteAccess); - $this->eventDispatcher + $this->siteAccessService ->expects(self::once()) - ->method('dispatch') - ->with(self::equalTo($event), MVCEvents::CONFIG_SCOPE_RESTORE); + ->method('restoreSiteAccess') + ->willReturn($originalSiteAccess); - $this->previewHelper->setSiteAccess($originalSiteAccess); self::assertEquals( $originalSiteAccess, $this->previewHelper->restoreConfigScope() @@ -80,7 +74,9 @@ public function testRestoreConfigScope() public function testPreviewActive() { $originalSiteAccess = new SiteAccess('foo', 'bar'); - $this->previewHelper->setSiteAccess($originalSiteAccess); + $this->siteAccessService + ->method('getCurrent') + ->willReturn($originalSiteAccess); self::assertFalse($this->previewHelper->isPreviewActive()); $this->previewHelper->setPreviewActive(true); @@ -88,7 +84,7 @@ public function testPreviewActive() $this->previewHelper->setPreviewActive(false); self::assertFalse($this->previewHelper->isPreviewActive()); - self::assertNotSame($originalSiteAccess, $this->previewHelper->getOriginalSiteAccess()); + self::assertSame($originalSiteAccess, $this->previewHelper->getOriginalSiteAccess()); } public function testPreviewedContent() diff --git a/tests/lib/MVC/Symfony/Routing/GeneratorTest.php b/tests/lib/MVC/Symfony/Routing/GeneratorTest.php index 51fc9a6c9e..8e4413b271 100644 --- a/tests/lib/MVC/Symfony/Routing/GeneratorTest.php +++ b/tests/lib/MVC/Symfony/Routing/GeneratorTest.php @@ -10,6 +10,7 @@ use Ibexa\Core\MVC\Symfony\Routing\Generator; use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\MVC\Symfony\SiteAccess\URILexer; use Ibexa\Core\Repository\Values\Content\Location; use PHPUnit\Framework\TestCase; @@ -59,7 +60,7 @@ public function generateProvider() public function testSimpleGenerate($urlResource, array $parameters, $referenceType) { $matcher = $this->createMock(URILexer::class); - $this->generator->setSiteAccess(new SiteAccess('test', 'fake', $matcher)); + $this->generator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $matcher))); $baseUrl = '/base/url'; $requestContext = new RequestContext($baseUrl); @@ -92,7 +93,7 @@ public function testSimpleGenerate($urlResource, array $parameters, $referenceTy public function testGenerateWithSiteAccessNoReverseMatch($urlResource, array $parameters, $referenceType) { $matcher = $this->createMock(URILexer::class); - $this->generator->setSiteAccess(new SiteAccess('test', 'test', $matcher)); + $this->generator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'test', $matcher))); $baseUrl = '/base/url'; $requestContext = new RequestContext($baseUrl); @@ -127,4 +128,15 @@ public function testGenerateWithSiteAccessNoReverseMatch($urlResource, array $pa ->method('notice'); self::assertSame($fullUri, $this->generator->generate($urlResource, $parameters + ['siteaccess' => $siteAccessName], $referenceType)); } + + /** + * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private function getSiteAccessService(SiteAccess $siteAccess) + { + $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $siteAccessService->method('getCurrent')->willReturn($siteAccess); + + return $siteAccessService; + } } diff --git a/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php b/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php index 845f3bdcca..df7bc325c2 100644 --- a/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php +++ b/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php @@ -16,6 +16,7 @@ use Ibexa\Core\MVC\Symfony\Routing\Generator\UrlAliasGenerator; use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\Repository\Mapper\RoleDomainMapper; use Ibexa\Core\Repository\Permission\LimitationService; use Ibexa\Core\Repository\Permission\PermissionResolver; @@ -176,7 +177,7 @@ public function testDoGenerate(URLAlias $urlAlias, array $parameters, $expected) ->with($location, false) ->will(self::returnValue([$urlAlias])); - $this->urlAliasGenerator->setSiteAccess(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class))); + $this->urlAliasGenerator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class)))); self::assertSame($expected, $this->urlAliasGenerator->doGenerate($location, $parameters)); } @@ -274,7 +275,7 @@ static function ($location) use ($treeRootUrlAlias) { ) ); - $this->urlAliasGenerator->setSiteAccess(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class))); + $this->urlAliasGenerator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class)))); self::assertSame($expected, $this->urlAliasGenerator->doGenerate($location, $parameters)); } @@ -385,10 +386,12 @@ public function testDoGenerateWithSiteAccessLoadsLocationWithLanguages(): void ->with($location, null, false, ['ger-DE']) ->willReturn($treeRootUrlAliases[$location->id]); - $this->urlAliasGenerator->setSiteAccess( - new SiteAccess( - $gerSiteAccess, - 'default', + $this->urlAliasGenerator->setSiteAccessService( + $this->getSiteAccessService( + new SiteAccess( + $gerSiteAccess, + 'default', + ) ) ); @@ -524,6 +527,17 @@ public function providerTestDoGenerateRootLocation() ]; } + /** + * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private function getSiteAccessService(SiteAccess $siteAccess) + { + $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $siteAccessService->method('getCurrent')->willReturn($siteAccess); + + return $siteAccessService; + } + protected function getPermissionResolverMock() { $configResolverMock = $this->createMock(ConfigResolverInterface::class); diff --git a/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php b/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php index d196b24bd0..ad73fa8051 100644 --- a/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php +++ b/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php @@ -9,6 +9,7 @@ use Ibexa\Core\MVC\Symfony\Security\HttpUtils; use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -22,7 +23,7 @@ public function testGenerateUriStandard($uri, $isUriRouteName, $expected) { $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $httpUtils = new HttpUtils($urlGenerator); - $httpUtils->setSiteAccess(new SiteAccess('test')); + $httpUtils->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test'))); $request = Request::create('http://ezpublish.dev/'); $request->attributes->set('siteaccess', new SiteAccess('test')); $requestAttributes = ['foo' => 'bar', 'some' => 'thing']; @@ -68,7 +69,7 @@ public function testGenerateUri($uri, $isUriRouteName, $siteAccessUri, $expected $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $httpUtils = new HttpUtils($urlGenerator); - $httpUtils->setSiteAccess($siteAccess); + $httpUtils->setSiteAccessService($this->getSiteAccessService($siteAccess)); $request = Request::create('http://ezpublish.dev/'); $request->attributes->set('siteaccess', $siteAccess); $requestAttributes = ['foo' => 'bar', 'some' => 'thing']; @@ -100,7 +101,7 @@ public function generateUriProvider() public function testCheckRequestPathStandard() { $httpUtils = new HttpUtils(); - $httpUtils->setSiteAccess(new SiteAccess('test')); + $httpUtils->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test'))); $request = Request::create('http://ezpublish.dev/foo/bar'); self::assertTrue($httpUtils->checkRequestPath($request, '/foo/bar')); } @@ -122,7 +123,7 @@ public function testCheckRequestPath($path, $siteAccessUri, $requestUri, $expect } $httpUtils = new HttpUtils(); - $httpUtils->setSiteAccess($siteAccess); + $httpUtils->setSiteAccessService($this->getSiteAccessService($siteAccess)); $request = Request::create($requestUri); self::assertSame($expected, $httpUtils->checkRequestPath($request, $path)); } @@ -138,4 +139,15 @@ public function checkRequestPathProvider() ['/foo/bar', '/blabla', 'http://ezpublish.dev/blabla/foo/bar', true], ]; } + + /** + * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject + */ + private function getSiteAccessService(SiteAccess $siteAccess) + { + $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $siteAccessService->method('getCurrent')->willReturn($siteAccess); + + return $siteAccessService; + } } diff --git a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php index 7ef230d7ee..d2714cce24 100644 --- a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php +++ b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php @@ -11,11 +11,18 @@ use ArrayIterator; use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; +use Ibexa\Core\MVC\Symfony\Event\PostSiteAccessMatchEvent; +use Ibexa\Core\MVC\Symfony\Event\ScopeChangeEvent; +use Ibexa\Core\MVC\Symfony\MVCEvents; use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\Provider\StaticSiteAccessProvider; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessProviderInterface; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\FinishRequestEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; class SiteAccessServiceTest extends TestCase { @@ -29,6 +36,9 @@ class SiteAccessServiceTest extends TestCase /** @var \Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface|\PHPUnit\Framework\MockObject\MockObject */ private $configResolver; + /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $eventDispatcher; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ private $siteAccess; @@ -43,26 +53,29 @@ protected function setUp(): void parent::setUp(); $this->provider = $this->createMock(SiteAccessProviderInterface::class); $this->configResolver = $this->createMock(ConfigResolverInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->siteAccess = new SiteAccess('current'); $this->availableSiteAccesses = $this->getAvailableSitAccesses(['current', 'first_sa', 'second_sa', 'default']); $this->configResolverParameters = $this->getConfigResolverParameters(); } - public function testGetCurrentSiteAccess(): void + public function testGetCurrentSiteAccessIsNullWhenStackIsEmpty(): void { - $service = new SiteAccessService( - $this->createMock(SiteAccessProviderInterface::class), - $this->createMock(ConfigResolverInterface::class) - ); + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); self::assertNull($service->getCurrent()); + } + + public function testGetCurrentSiteAccessAfterMainRequestMatch(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); $siteAccess = new SiteAccess('default'); - $service->setSiteAccess($siteAccess); - self::assertSame($siteAccess, $service->getCurrent()); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($siteAccess, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); - $service->setSiteAccess(null); - self::assertNull($service->getCurrent()); + self::assertSame($siteAccess, $service->getCurrent()); } public function testGetSiteAccess(): void @@ -73,7 +86,8 @@ public function testGetSiteAccess(): void ); $service = new SiteAccessService( $staticSiteAccessProvider, - $this->createMock(ConfigResolverInterface::class) + $this->createMock(ConfigResolverInterface::class), + $this->eventDispatcher ); self::assertEquals( @@ -90,7 +104,8 @@ public function testGetSiteAccessThrowsNotFoundException(): void ); $service = new SiteAccessService( $staticSiteAccessProvider, - $this->createMock(ConfigResolverInterface::class) + $this->createMock(ConfigResolverInterface::class), + $this->eventDispatcher ); $this->expectException(NotFoundException::class); @@ -126,10 +141,150 @@ public function testGetFirstSiteAccessesRelation(): void ); } + /** + * changeSiteAccess()/restoreSiteAccess() must behave as a real LIFO stack, and each call + * must dispatch a ScopeChangeEvent carrying the relevant SiteAccess under the right event name. + */ + public function testChangeSiteAccessAndRestoreSiteAccessBehaveAsLifoStack(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + + $original = new SiteAccess('original'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($original, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + + $first = new SiteAccess('first_change'); + $second = new SiteAccess('second_change'); + + $this->eventDispatcher + ->expects(self::exactly(4)) + ->method('dispatch') + ->withConsecutive( + [self::equalTo(new ScopeChangeEvent($first)), MVCEvents::CONFIG_SCOPE_CHANGE], + [self::equalTo(new ScopeChangeEvent($second)), MVCEvents::CONFIG_SCOPE_CHANGE], + [self::equalTo(new ScopeChangeEvent($first)), MVCEvents::CONFIG_SCOPE_RESTORE], + [self::equalTo(new ScopeChangeEvent($original)), MVCEvents::CONFIG_SCOPE_RESTORE] + ); + + self::assertSame($first, $service->changeSiteAccess($first)); + self::assertSame($first, $service->getCurrent()); + + self::assertSame($second, $service->changeSiteAccess($second)); + self::assertSame($second, $service->getCurrent()); + + self::assertSame($first, $service->restoreSiteAccess()); + self::assertSame($first, $service->getCurrent()); + + self::assertSame($original, $service->restoreSiteAccess()); + self::assertSame($original, $service->getCurrent()); + } + + public function testOnSiteAccessMatchWithMainRequestResetsTheStack(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent(new SiteAccess('first'), new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent(new SiteAccess('sub'), new Request(), HttpKernelInterface::SUB_REQUEST) + ); + + $second = new SiteAccess('second'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($second, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + + self::assertSame($second, $service->getCurrent()); + + // The stack was fully reset by the second MAIN_REQUEST match: an unbalanced + // finish-request must not be able to reveal any of the previous entries. + $service->onKernelFinishRequest($this->createFinishRequestEvent()); + self::assertSame($second, $service->getCurrent()); + } + + public function testOnSiteAccessMatchWithSubRequestPushesWithoutResetting(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + + $main = new SiteAccess('main'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($main, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + + $sub = new SiteAccess('sub'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($sub, new Request(), HttpKernelInterface::SUB_REQUEST) + ); + + self::assertSame($sub, $service->getCurrent()); + + $service->onKernelFinishRequest($this->createFinishRequestEvent()); + self::assertSame($main, $service->getCurrent()); + } + + public function testOnKernelFinishRequestNeverPopsBelowOneRemainingEntry(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + + $original = new SiteAccess('original'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($original, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + + // Simulate an extra, unbalanced finish-request event. + $service->onKernelFinishRequest($this->createFinishRequestEvent()); + + self::assertSame($original, $service->getCurrent()); + } + + /** + * Sub-request nesting scenario: a preview scope change followed by a sub-request (e.g. a + * fragment/ESI render) must unwind back to the preview scope once the sub-request finishes, + * and restoreSiteAccess() must then bring back the original SiteAccess. + */ + public function testSubRequestNestingScenario(): void + { + $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + + $original = new SiteAccess('original'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($original, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); + + $preview = new SiteAccess('preview'); + $service->changeSiteAccess($preview); + self::assertSame($preview, $service->getCurrent()); + + $subSiteAccess = new SiteAccess('sub'); + $service->onSiteAccessMatch( + new PostSiteAccessMatchEvent($subSiteAccess, new Request(), HttpKernelInterface::SUB_REQUEST) + ); + self::assertSame($subSiteAccess, $service->getCurrent()); + + $service->onKernelFinishRequest($this->createFinishRequestEvent()); + self::assertSame($preview, $service->getCurrent()); + + self::assertSame($original, $service->restoreSiteAccess()); + self::assertSame($original, $service->getCurrent()); + } + + private function createFinishRequestEvent(): FinishRequestEvent + { + return new FinishRequestEvent( + $this->createMock(HttpKernelInterface::class), + new Request(), + HttpKernelInterface::MAIN_REQUEST + ); + } + private function getSiteAccessService(): SiteAccessService { - $siteAccessService = new SiteAccessService($this->provider, $this->configResolver); - $siteAccessService->setSiteAccess($this->siteAccess); + $siteAccessService = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $siteAccessService->onSiteAccessMatch( + new PostSiteAccessMatchEvent($this->siteAccess, new Request(), HttpKernelInterface::MAIN_REQUEST) + ); return $siteAccessService; } From 8f6c1495bc1d9e0b06acc02ca212fd7872357d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Thu, 6 Aug 2026 09:51:58 +0200 Subject: [PATCH 02/11] IBX-12204: Inject SiteAccessServiceInterface via constructor, not a setter Generator (and UrlAliasGenerator), HttpUtils, and DefaultRouter now receive SiteAccessServiceInterface as a constructor dependency instead of through a setSiteAccessService() call. For the two classes extending Symfony framework base classes (HttpUtils, DefaultRouter), the new parameter is appended as a nullable, named-argument-bound constructor parameter so the existing positional arguments coming from Symfony's own service definitions are left untouched. --- .../Compiler/ChainRoutingPass.php | 2 +- .../Compiler/SecurityPass.php | 5 +--- src/bundle/Core/Resources/config/routing.yml | 2 +- src/bundle/Core/Routing/DefaultRouter.php | 23 ++++++++++---- src/lib/MVC/Symfony/Routing/Generator.php | 15 ++++------ .../Routing/Generator/UrlAliasGenerator.php | 12 ++++++-- src/lib/MVC/Symfony/Security/HttpUtils.php | 16 ++++++---- .../Compiler/ChainRoutingPassTest.php | 6 ++-- .../Compiler/SecurityPassTest.php | 6 ++-- .../bundle/Core/Routing/DefaultRouterTest.php | 19 ++++++++---- .../lib/MVC/Symfony/Routing/GeneratorTest.php | 21 +++++-------- .../Symfony/Routing/UrlAliasGeneratorTest.php | 30 +++++++------------ .../MVC/Symfony/Security/HttpUtilsTest.php | 12 +++----- 13 files changed, 88 insertions(+), 81 deletions(-) diff --git a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php index 30df618e99..adc4b29a68 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php @@ -28,7 +28,7 @@ public function process(ContainerBuilder $container): void // The default router will be given the highest priority so that it will be used by default if ($container->hasDefinition('router.default')) { $defaultRouter = $container->getDefinition('router.default'); - $defaultRouter->addMethodCall('setSiteAccessService', [new Reference(SiteAccessServiceInterface::class)]); + $defaultRouter->setArgument('$siteAccessService', new Reference(SiteAccessServiceInterface::class)); $defaultRouter->addMethodCall('setConfigResolver', [new Reference('ibexa.config.resolver')]); $defaultRouter->addMethodCall( 'setNonSiteAccessAwareRoutes', diff --git a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php index 3271e1637e..7c9a2b3e4b 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/SecurityPass.php @@ -39,10 +39,7 @@ private function registerHttpUtils(ContainerBuilder $container): void $httpUtilsDef = $container->findDefinition('security.http_utils'); $httpUtilsDef->setClass(HttpUtils::class); - $httpUtilsDef->addMethodCall( - 'setSiteAccessService', - [new Reference(SiteAccessServiceInterface::class)] - ); + $httpUtilsDef->setArgument('$siteAccessService', new Reference(SiteAccessServiceInterface::class)); } private function registerSecurityAuthenticationSuccessHandler(ContainerBuilder $container): void diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index b326768cf2..78a441bd46 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -31,7 +31,6 @@ services: abstract: true calls: - [setRequestContext, ["@router.request_context"]] - - [setSiteAccessService, ['@?Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService']] - [setSiteAccessRouter, ['@Ibexa\Core\MVC\Symfony\SiteAccess\Router']] - [setLogger, ["@?logger"]] @@ -55,6 +54,7 @@ services: - '@ibexa.api.repository' - "@router.default" - '@ibexa.config.resolver' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' - '%ibexa.urlalias_generator.charmap%' parent: Ibexa\Core\MVC\Symfony\Routing\Generator diff --git a/src/bundle/Core/Routing/DefaultRouter.php b/src/bundle/Core/Routing/DefaultRouter.php index 9f91127ebc..b0f3f06a45 100644 --- a/src/bundle/Core/Routing/DefaultRouter.php +++ b/src/bundle/Core/Routing/DefaultRouter.php @@ -14,6 +14,8 @@ use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessRouterInterface; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\MVC\Symfony\SiteAccess\URILexer; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Routing\Router; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Exception\RouteNotFoundException; @@ -31,18 +33,27 @@ class DefaultRouter extends Router protected SiteAccessRouterInterface $siteAccessRouter; - private ?SiteAccessServiceInterface $siteAccessService = null; + /** + * @param array $options + */ + public function __construct( + ContainerInterface $container, + mixed $resource, + array $options = [], + ?RequestContext $context = null, + ?ContainerInterface $parameters = null, + ?LoggerInterface $logger = null, + ?string $defaultLocale = null, + private ?SiteAccessServiceInterface $siteAccessService = null, + ) { + parent::__construct($container, $resource, $options, $context, $parameters, $logger, $defaultLocale); + } public function setConfigResolver(ConfigResolverInterface $configResolver): void { $this->configResolver = $configResolver; } - public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void - { - $this->siteAccessService = $siteAccessService; - } - /** * Injects route names that are not supposed to be SiteAccess aware. * i.e. Routes pointing to asset generation (like assetic). diff --git a/src/lib/MVC/Symfony/Routing/Generator.php b/src/lib/MVC/Symfony/Routing/Generator.php index b58d64ba0a..16d8982e8a 100644 --- a/src/lib/MVC/Symfony/Routing/Generator.php +++ b/src/lib/MVC/Symfony/Routing/Generator.php @@ -23,10 +23,12 @@ abstract class Generator protected SiteAccessRouterInterface $siteAccessRouter; - private ?SiteAccessServiceInterface $siteAccessService = null; - protected ?LoggerInterface $logger; + public function __construct(private readonly SiteAccessServiceInterface $siteAccessService) + { + } + public function setRequestContext(RequestContext $requestContext): void { $this->requestContext = $requestContext; @@ -37,11 +39,6 @@ public function setSiteAccessRouter(SiteAccessRouterInterface $siteAccessRouter) $this->siteAccessRouter = $siteAccessRouter; } - public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void - { - $this->siteAccessService = $siteAccessService; - } - public function setLogger(?LoggerInterface $logger = null): void { $this->logger = $logger; @@ -57,7 +54,7 @@ public function setLogger(?LoggerInterface $logger = null): void */ public function generate(mixed $urlResource, array $parameters, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string { - $siteAccess = $this->siteAccessService?->getCurrent(); + $siteAccess = $this->siteAccessService->getCurrent(); $requestContext = $this->requestContext; // Retrieving the appropriate SiteAccess to generate the link for. @@ -69,7 +66,7 @@ public function generate(mixed $urlResource, array $parameters, int $referenceTy $siteAccess->matcher->getRequest() ); } elseif (isset($this->logger)) { - $siteAccess = $this->siteAccessService?->getCurrent(); + $siteAccess = $this->siteAccessService->getCurrent(); $this->logger->notice("Could not generate a link using provided 'siteaccess' parameter: {$parameters['siteaccess']}. Generating using current context."); unset($parameters['siteaccess']); } diff --git a/src/lib/MVC/Symfony/Routing/Generator/UrlAliasGenerator.php b/src/lib/MVC/Symfony/Routing/Generator/UrlAliasGenerator.php index de2c8b66f2..ae10adaa2d 100644 --- a/src/lib/MVC/Symfony/Routing/Generator/UrlAliasGenerator.php +++ b/src/lib/MVC/Symfony/Routing/Generator/UrlAliasGenerator.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\Location; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\MVC\Symfony\Routing\Generator; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\Routing\RouterInterface; /** @@ -47,8 +48,15 @@ class UrlAliasGenerator extends Generator /** * @param array $unsafeCharMap */ - public function __construct(Repository $repository, RouterInterface $defaultRouter, ConfigResolverInterface $configResolver, array $unsafeCharMap = []) - { + public function __construct( + Repository $repository, + RouterInterface $defaultRouter, + ConfigResolverInterface $configResolver, + SiteAccessServiceInterface $siteAccessService, + array $unsafeCharMap = [] + ) { + parent::__construct($siteAccessService); + $this->repository = $repository; $this->defaultRouter = $defaultRouter; $this->configResolver = $configResolver; diff --git a/src/lib/MVC/Symfony/Security/HttpUtils.php b/src/lib/MVC/Symfony/Security/HttpUtils.php index 0f4125d8ff..9000c9674e 100644 --- a/src/lib/MVC/Symfony/Security/HttpUtils.php +++ b/src/lib/MVC/Symfony/Security/HttpUtils.php @@ -10,15 +10,21 @@ use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\Matcher\RequestMatcherInterface; +use Symfony\Component\Routing\Matcher\UrlMatcherInterface; use Symfony\Component\Security\Http\HttpUtils as BaseHttpUtils; class HttpUtils extends BaseHttpUtils { - private ?SiteAccessServiceInterface $siteAccessService = null; - - public function setSiteAccessService(?SiteAccessServiceInterface $siteAccessService = null): void - { - $this->siteAccessService = $siteAccessService; + public function __construct( + ?UrlGeneratorInterface $urlGenerator = null, + UrlMatcherInterface|RequestMatcherInterface|null $urlMatcher = null, + ?string $domainRegexp = null, + ?string $secureDomainRegexp = null, + private ?SiteAccessServiceInterface $siteAccessService = null, + ) { + parent::__construct($urlGenerator, $urlMatcher, $domainRegexp, $secureDomainRegexp); } private function analyzeLink(string $path): string diff --git a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php index 2f0ac07e96..77c6d26888 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php @@ -90,10 +90,10 @@ public function testAddRouterWithDefaultRouter($declaredPriority, $expectedPrior $this->compile(); // Assertion for default router - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + $this->assertContainerBuilderHasServiceDefinitionWithArgument( 'router.default', - 'setSiteAccessService', - [new Reference(SiteAccessServiceInterface::class)] + '$siteAccessService', + new Reference(SiteAccessServiceInterface::class) ); $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( 'router.default', diff --git a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php index f47eda8ac0..c4b1a87eef 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/SecurityPassTest.php @@ -43,10 +43,10 @@ public function testAlteredHttpUtils(): void { $this->compile(); - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( + $this->assertContainerBuilderHasServiceDefinitionWithArgument( 'security.http_utils', - 'setSiteAccessService', - [new Reference(SiteAccessServiceInterface::class)] + '$siteAccessService', + new Reference(SiteAccessServiceInterface::class) ); } diff --git a/tests/bundle/Core/Routing/DefaultRouterTest.php b/tests/bundle/Core/Routing/DefaultRouterTest.php index 8d8d94a356..58410d3962 100644 --- a/tests/bundle/Core/Routing/DefaultRouterTest.php +++ b/tests/bundle/Core/Routing/DefaultRouterTest.php @@ -53,12 +53,12 @@ protected function getRouterClass(): string * * @return \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter */ - protected function generateRouter(array $mockedMethods = []) + protected function generateRouter(array $mockedMethods = [], ?SiteAccessServiceInterface $siteAccessService = null) { /** @var \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter $router */ $router = $this ->getMockBuilder($this->getRouterClass()) - ->setConstructorArgs([$this->container, 'foo', [], $this->requestContext]) + ->setConstructorArgs([$this->container, 'foo', [], $this->requestContext, null, null, null, $siteAccessService]) ->setMethods(array_merge($mockedMethods)) ->getMock(); $router->setConfigResolver($this->configResolver); @@ -171,8 +171,10 @@ public function testGenerateWithSiteAccess($urlGenerated, $relevantUri, $expecte ->with($routeName) ->willReturn($urlGenerated); + $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + /** @var \Ibexa\Bundle\Core\Routing\DefaultRouter&\PHPUnit\Framework\MockObject\MockObject $router */ - $router = $this->generateRouter(['getGenerator']); + $router = $this->generateRouter(['getGenerator'], $siteAccessService); $router ->expects(self::any()) ->method('getGenerator') @@ -199,7 +201,7 @@ public function testGenerateWithSiteAccess($urlGenerated, $relevantUri, $expecte } $sa = new SiteAccess($saName, 'test', $matcher); - $router->setSiteAccessService($this->getSiteAccessService($sa)); + $siteAccessService->method('getCurrent')->willReturn($sa); $requestContext = new RequestContext(); $urlComponents = parse_url($urlGenerated); @@ -273,9 +275,14 @@ public function testGenerateReverseSiteAccessMatch() ->method('setContext') ->with($this->requestContext); - $router = new DefaultRouter($this->container, 'foo', [], $this->requestContext); + $router = new DefaultRouter( + $this->container, + 'foo', + [], + $this->requestContext, + siteAccessService: $this->getSiteAccessService(new SiteAccess('test', 'test', $this->createMock(Matcher::class))) + ); $router->setConfigResolver($this->configResolver); - $router->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'test', $this->createMock(Matcher::class)))); $router->setSiteAccessRouter($siteAccessRouter); $refRouter = new ReflectionObject($router); $refGenerator = $refRouter->getProperty('generator'); diff --git a/tests/lib/MVC/Symfony/Routing/GeneratorTest.php b/tests/lib/MVC/Symfony/Routing/GeneratorTest.php index 8e4413b271..dcf4248323 100644 --- a/tests/lib/MVC/Symfony/Routing/GeneratorTest.php +++ b/tests/lib/MVC/Symfony/Routing/GeneratorTest.php @@ -29,12 +29,16 @@ class GeneratorTest extends TestCase /** @var \PHPUnit\Framework\MockObject\MockObject */ private $logger; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $siteAccessService; + protected function setUp(): void { parent::setUp(); $this->siteAccessRouter = $this->createMock(SiteAccessRouterInterface::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->generator = $this->getMockForAbstractClass(Generator::class); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); + $this->generator = $this->getMockForAbstractClass(Generator::class, [$this->siteAccessService]); $this->generator->setSiteAccessRouter($this->siteAccessRouter); $this->generator->setLogger($this->logger); } @@ -60,7 +64,7 @@ public function generateProvider() public function testSimpleGenerate($urlResource, array $parameters, $referenceType) { $matcher = $this->createMock(URILexer::class); - $this->generator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $matcher))); + $this->siteAccessService->method('getCurrent')->willReturn(new SiteAccess('test', 'fake', $matcher)); $baseUrl = '/base/url'; $requestContext = new RequestContext($baseUrl); @@ -93,7 +97,7 @@ public function testSimpleGenerate($urlResource, array $parameters, $referenceTy public function testGenerateWithSiteAccessNoReverseMatch($urlResource, array $parameters, $referenceType) { $matcher = $this->createMock(URILexer::class); - $this->generator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'test', $matcher))); + $this->siteAccessService->method('getCurrent')->willReturn(new SiteAccess('test', 'test', $matcher)); $baseUrl = '/base/url'; $requestContext = new RequestContext($baseUrl); @@ -128,15 +132,4 @@ public function testGenerateWithSiteAccessNoReverseMatch($urlResource, array $pa ->method('notice'); self::assertSame($fullUri, $this->generator->generate($urlResource, $parameters + ['siteaccess' => $siteAccessName], $referenceType)); } - - /** - * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject - */ - private function getSiteAccessService(SiteAccess $siteAccess) - { - $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); - $siteAccessService->method('getCurrent')->willReturn($siteAccess); - - return $siteAccessService; - } } diff --git a/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php b/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php index df7bc325c2..463e9b7994 100644 --- a/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php +++ b/tests/lib/MVC/Symfony/Routing/UrlAliasGeneratorTest.php @@ -52,6 +52,9 @@ class UrlAliasGeneratorTest extends TestCase /** @var \PHPUnit\Framework\MockObject\MockObject */ private $configResolver; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $siteAccessService; + protected function setUp(): void { parent::setUp(); @@ -59,6 +62,7 @@ protected function setUp(): void $this->logger = $this->createMock(LoggerInterface::class); $this->siteAccessRouter = $this->createMock(SiteAccessRouterInterface::class); $this->configResolver = $this->createMock(ConfigResolverInterface::class); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); $repositoryClass = Repository::class; $this->repository = $repository = $this ->getMockBuilder($repositoryClass) @@ -95,6 +99,7 @@ protected function setUp(): void $this->repository, $this->router, $this->configResolver, + $this->siteAccessService, $urlAliasCharmap ); $this->urlAliasGenerator->setLogger($this->logger); @@ -177,7 +182,7 @@ public function testDoGenerate(URLAlias $urlAlias, array $parameters, $expected) ->with($location, false) ->will(self::returnValue([$urlAlias])); - $this->urlAliasGenerator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class)))); + $this->siteAccessService->method('getCurrent')->willReturn(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class))); self::assertSame($expected, $this->urlAliasGenerator->doGenerate($location, $parameters)); } @@ -275,7 +280,7 @@ static function ($location) use ($treeRootUrlAlias) { ) ); - $this->urlAliasGenerator->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class)))); + $this->siteAccessService->method('getCurrent')->willReturn(new SiteAccess('test', 'fake', $this->createMock(SiteAccess\URILexer::class))); self::assertSame($expected, $this->urlAliasGenerator->doGenerate($location, $parameters)); } @@ -386,12 +391,10 @@ public function testDoGenerateWithSiteAccessLoadsLocationWithLanguages(): void ->with($location, null, false, ['ger-DE']) ->willReturn($treeRootUrlAliases[$location->id]); - $this->urlAliasGenerator->setSiteAccessService( - $this->getSiteAccessService( - new SiteAccess( - $gerSiteAccess, - 'default', - ) + $this->siteAccessService->method('getCurrent')->willReturn( + new SiteAccess( + $gerSiteAccess, + 'default', ) ); @@ -527,17 +530,6 @@ public function providerTestDoGenerateRootLocation() ]; } - /** - * @return \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject - */ - private function getSiteAccessService(SiteAccess $siteAccess) - { - $siteAccessService = $this->createMock(SiteAccessServiceInterface::class); - $siteAccessService->method('getCurrent')->willReturn($siteAccess); - - return $siteAccessService; - } - protected function getPermissionResolverMock() { $configResolverMock = $this->createMock(ConfigResolverInterface::class); diff --git a/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php b/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php index ad73fa8051..efb5d8fa3e 100644 --- a/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php +++ b/tests/lib/MVC/Symfony/Security/HttpUtilsTest.php @@ -22,8 +22,7 @@ class HttpUtilsTest extends TestCase public function testGenerateUriStandard($uri, $isUriRouteName, $expected) { $urlGenerator = $this->createMock(UrlGeneratorInterface::class); - $httpUtils = new HttpUtils($urlGenerator); - $httpUtils->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test'))); + $httpUtils = new HttpUtils($urlGenerator, siteAccessService: $this->getSiteAccessService(new SiteAccess('test'))); $request = Request::create('http://ezpublish.dev/'); $request->attributes->set('siteaccess', new SiteAccess('test')); $requestAttributes = ['foo' => 'bar', 'some' => 'thing']; @@ -68,8 +67,7 @@ public function testGenerateUri($uri, $isUriRouteName, $siteAccessUri, $expected } $urlGenerator = $this->createMock(UrlGeneratorInterface::class); - $httpUtils = new HttpUtils($urlGenerator); - $httpUtils->setSiteAccessService($this->getSiteAccessService($siteAccess)); + $httpUtils = new HttpUtils($urlGenerator, siteAccessService: $this->getSiteAccessService($siteAccess)); $request = Request::create('http://ezpublish.dev/'); $request->attributes->set('siteaccess', $siteAccess); $requestAttributes = ['foo' => 'bar', 'some' => 'thing']; @@ -100,8 +98,7 @@ public function generateUriProvider() public function testCheckRequestPathStandard() { - $httpUtils = new HttpUtils(); - $httpUtils->setSiteAccessService($this->getSiteAccessService(new SiteAccess('test'))); + $httpUtils = new HttpUtils(siteAccessService: $this->getSiteAccessService(new SiteAccess('test'))); $request = Request::create('http://ezpublish.dev/foo/bar'); self::assertTrue($httpUtils->checkRequestPath($request, '/foo/bar')); } @@ -122,8 +119,7 @@ public function testCheckRequestPath($path, $siteAccessUri, $requestUri, $expect $siteAccess->matcher = $matcher; } - $httpUtils = new HttpUtils(); - $httpUtils->setSiteAccessService($this->getSiteAccessService($siteAccess)); + $httpUtils = new HttpUtils(siteAccessService: $this->getSiteAccessService($siteAccess)); $request = Request::create($requestUri); self::assertSame($expected, $httpUtils->checkRequestPath($request, $path)); } From 1712b082f4f03d49fb98351d40bd3c55bc4dc947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Thu, 6 Aug 2026 10:06:53 +0200 Subject: [PATCH 03/11] IBX-12204: Fix UrlAliasRouterTest for UrlAliasGenerator's new constructor arg UrlAliasGenerator::__construct() now requires SiteAccessServiceInterface as its 4th argument; the test built the generator via getMockBuilder() with only 3 constructor args, causing an ArgumentCountError on both PHP 8.3 and 8.4 CI jobs. --- tests/lib/MVC/Symfony/Routing/UrlAliasRouterTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/lib/MVC/Symfony/Routing/UrlAliasRouterTest.php b/tests/lib/MVC/Symfony/Routing/UrlAliasRouterTest.php index 52ebd9be49..aaffaee777 100644 --- a/tests/lib/MVC/Symfony/Routing/UrlAliasRouterTest.php +++ b/tests/lib/MVC/Symfony/Routing/UrlAliasRouterTest.php @@ -18,6 +18,7 @@ use Ibexa\Core\MVC\Symfony\Routing\UrlAliasRouter; use Ibexa\Core\MVC\Symfony\SiteAccess; use Ibexa\Core\MVC\Symfony\SiteAccess\Matcher; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Core\MVC\Symfony\View\Manager as ViewManager; use Ibexa\Core\Repository\Repository; use Ibexa\Core\Repository\Values\Content\Location; @@ -77,6 +78,7 @@ protected function setUp(): void $repository, $this->createMock(RouterInterface::class), $this->createMock(ConfigResolverInterface::class), + $this->createMock(SiteAccessServiceInterface::class), ] ) ->getMock(); From 8717388d12367cab90a5a36da3a0ec9915c6d79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Thu, 6 Aug 2026 10:31:27 +0200 Subject: [PATCH 04/11] IBX-12204: Seed SiteAccessService's stack with the default SiteAccess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing SiteAccessAware from SiteAccessService also dropped the old setSiteAccess() call that seeded it with the shared, container-wide default SiteAccess singleton at construction time. That meant getCurrent() went from "never null once the container is built" to genuinely null until the first PostSiteAccessMatchEvent, breaking any code that reads it outside of an HTTP request cycle (integration tests, CLI warm-up, etc.) — surfaced by ComplexConfigProcessor/IOConfigResolver now throwing on IO-related integration tests. SiteAccessService now takes that shared SiteAccess singleton as a 4th constructor argument and seeds the stack with it directly (no event dispatch, matching the old setSiteAccess() semantics exactly), restoring the pre-existing guarantee. --- src/bundle/Core/Resources/config/routing.yml | 1 + .../Symfony/SiteAccess/SiteAccessService.php | 14 +++++- .../Core/Resources/settings/common.yml | 1 + .../SiteAccess/SiteAccessServiceTest.php | 45 ++++++++++++++----- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index 78a441bd46..748cc90d3f 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -89,6 +89,7 @@ services: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - '@event_dispatcher' + - '@Ibexa\Core\MVC\Symfony\SiteAccess' tags: - { name: kernel.event_subscriber } diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php index 1cab85b62a..0e50aa018f 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php @@ -25,13 +25,23 @@ class SiteAccessService implements SiteAccessServiceInterface, EventSubscriberInterface { /** @var \Ibexa\Core\MVC\Symfony\SiteAccess[] */ - private array $siteAccessStack = []; + private array $siteAccessStack; + /** + * @param \Ibexa\Core\MVC\Symfony\SiteAccess $siteAccess the shared, container-wide default + * SiteAccess, used as the initial stack entry until a real request (or an explicit + * changeSiteAccess() call) establishes the actual current one. This preserves + * getCurrent()'s pre-existing guarantee of never being null once the container is built, + * which code such as ComplexConfigProcessor relies on outside of an HTTP request cycle + * (CLI warm-up, integration tests, etc.). + */ public function __construct( private readonly SiteAccessProviderInterface $provider, private readonly ConfigResolverInterface $configResolver, - private readonly EventDispatcherInterface $eventDispatcher + private readonly EventDispatcherInterface $eventDispatcher, + SiteAccess $siteAccess ) { + $this->siteAccessStack = [$siteAccess]; } public static function getSubscribedEvents(): array diff --git a/tests/integration/Core/Resources/settings/common.yml b/tests/integration/Core/Resources/settings/common.yml index fc62034173..d1d1d8d668 100644 --- a/tests/integration/Core/Resources/settings/common.yml +++ b/tests/integration/Core/Resources/settings/common.yml @@ -112,6 +112,7 @@ services: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - '@Symfony\Component\EventDispatcher\EventDispatcher' + - '@Ibexa\Core\MVC\Symfony\SiteAccess' Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface: alias: 'Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' diff --git a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php index d2714cce24..4fc4d986f8 100644 --- a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php +++ b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php @@ -42,6 +42,9 @@ class SiteAccessServiceTest extends TestCase /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ private $siteAccess; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ + private $defaultSiteAccess; + /** @var \ArrayIterator */ private $availableSiteAccesses; @@ -55,20 +58,26 @@ protected function setUp(): void $this->configResolver = $this->createMock(ConfigResolverInterface::class); $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->siteAccess = new SiteAccess('current'); + $this->defaultSiteAccess = new SiteAccess('uninitialized_default', SiteAccess::MATCHING_TYPE_UNINITIALIZED); $this->availableSiteAccesses = $this->getAvailableSitAccesses(['current', 'first_sa', 'second_sa', 'default']); $this->configResolverParameters = $this->getConfigResolverParameters(); } - public function testGetCurrentSiteAccessIsNullWhenStackIsEmpty(): void + /** + * Before any request has matched, getCurrent() must return the constructor-seeded default + * SiteAccess rather than null — this preserves the pre-existing guarantee (relied on by e.g. + * ComplexConfigProcessor) that getCurrent() is never null once the container is built. + */ + public function testGetCurrentSiteAccessReturnsSeedBeforeAnyMatch(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); - self::assertNull($service->getCurrent()); + self::assertSame($this->defaultSiteAccess, $service->getCurrent()); } public function testGetCurrentSiteAccessAfterMainRequestMatch(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $siteAccess = new SiteAccess('default'); $service->onSiteAccessMatch( @@ -87,7 +96,8 @@ public function testGetSiteAccess(): void $service = new SiteAccessService( $staticSiteAccessProvider, $this->createMock(ConfigResolverInterface::class), - $this->eventDispatcher + $this->eventDispatcher, + $this->defaultSiteAccess ); self::assertEquals( @@ -105,7 +115,8 @@ public function testGetSiteAccessThrowsNotFoundException(): void $service = new SiteAccessService( $staticSiteAccessProvider, $this->createMock(ConfigResolverInterface::class), - $this->eventDispatcher + $this->eventDispatcher, + $this->defaultSiteAccess ); $this->expectException(NotFoundException::class); @@ -147,7 +158,7 @@ public function testGetFirstSiteAccessesRelation(): void */ public function testChangeSiteAccessAndRestoreSiteAccessBehaveAsLifoStack(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $original = new SiteAccess('original'); $service->onSiteAccessMatch( @@ -182,7 +193,7 @@ public function testChangeSiteAccessAndRestoreSiteAccessBehaveAsLifoStack(): voi public function testOnSiteAccessMatchWithMainRequestResetsTheStack(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $service->onSiteAccessMatch( new PostSiteAccessMatchEvent(new SiteAccess('first'), new Request(), HttpKernelInterface::MAIN_REQUEST) @@ -206,7 +217,7 @@ public function testOnSiteAccessMatchWithMainRequestResetsTheStack(): void public function testOnSiteAccessMatchWithSubRequestPushesWithoutResetting(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $main = new SiteAccess('main'); $service->onSiteAccessMatch( @@ -226,7 +237,7 @@ public function testOnSiteAccessMatchWithSubRequestPushesWithoutResetting(): voi public function testOnKernelFinishRequestNeverPopsBelowOneRemainingEntry(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $original = new SiteAccess('original'); $service->onSiteAccessMatch( @@ -246,7 +257,7 @@ public function testOnKernelFinishRequestNeverPopsBelowOneRemainingEntry(): void */ public function testSubRequestNestingScenario(): void { - $service = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $service = $this->createSiteAccessService(); $original = new SiteAccess('original'); $service->onSiteAccessMatch( @@ -279,9 +290,19 @@ private function createFinishRequestEvent(): FinishRequestEvent ); } + private function createSiteAccessService(): SiteAccessService + { + return new SiteAccessService( + $this->provider, + $this->configResolver, + $this->eventDispatcher, + $this->defaultSiteAccess + ); + } + private function getSiteAccessService(): SiteAccessService { - $siteAccessService = new SiteAccessService($this->provider, $this->configResolver, $this->eventDispatcher); + $siteAccessService = $this->createSiteAccessService(); $siteAccessService->onSiteAccessMatch( new PostSiteAccessMatchEvent($this->siteAccess, new Request(), HttpKernelInterface::MAIN_REQUEST) ); From ffde67d8c0c3437e968b705163e10bc8e7e8ed7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 11:40:11 +0200 Subject: [PATCH 05/11] IBX-12204: Stop relying on the shared SiteAccess singleton service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SiteAccessService no longer takes the shared, container-wide SiteAccess as a constructor dependency, and getCurrent() goes back to being null until a real request match or an explicit changeSiteAccess() call establishes one. Its "uninitialized" matcher (SiteAccess::MATCHING_TYPE_UNINITIALIZED) is treated the same way defensively, should it ever end up on the stack. ConsoleCommandListener no longer mutates the shared SiteAccess singleton in place either: it now constructs a fresh SiteAccess for the CLI invocation and hands it to SiteAccessService::changeSiteAccess(), which it already called. IO/repository integration tests (IbexaTestKernel and LegacyTestContainerBuilder) never dispatch a request or console command, so they now explicitly seed SiteAccessService via changeSiteAccess() with a freshly-constructed default SiteAccess — the same mechanism CLI uses, not a shared/mutated singleton. --- .../EventListener/ConsoleCommandListener.php | 21 ++++------- src/bundle/Core/Resources/config/routing.yml | 1 - src/bundle/Core/Resources/config/services.yml | 1 - src/contracts/Test/IbexaTestKernel.php | 35 +++++++++++++++++++ .../Symfony/SiteAccess/SiteAccessService.php | 25 +++++++------ .../ConsoleCommandListenerTest.php | 11 ++---- .../Core/Resources/settings/common.yml | 10 +++++- .../SiteAccess/SiteAccessServiceTest.php | 31 ++++++++-------- 8 files changed, 80 insertions(+), 55 deletions(-) diff --git a/src/bundle/Core/EventListener/ConsoleCommandListener.php b/src/bundle/Core/EventListener/ConsoleCommandListener.php index ed621212f4..fd5af47a96 100644 --- a/src/bundle/Core/EventListener/ConsoleCommandListener.php +++ b/src/bundle/Core/EventListener/ConsoleCommandListener.php @@ -22,9 +22,6 @@ class ConsoleCommandListener implements EventSubscriberInterface /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessProviderInterface */ private $siteAccessProvider; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - private $siteAccess; - private SiteAccessServiceInterface $siteAccessService; /** @var bool */ @@ -33,13 +30,11 @@ class ConsoleCommandListener implements EventSubscriberInterface public function __construct( string $defaultSiteAccessName, SiteAccess\SiteAccessProviderInterface $siteAccessProvider, - SiteAccess $siteAccess, SiteAccessServiceInterface $siteAccessService, bool $debug = false ) { $this->defaultSiteAccessName = $defaultSiteAccessName; $this->siteAccessProvider = $siteAccessProvider; - $this->siteAccess = $siteAccess; $this->siteAccessService = $siteAccessService; $this->debug = $debug; } @@ -55,23 +50,19 @@ public static function getSubscribedEvents(): array public function onConsoleCommand(ConsoleCommandEvent $event) { - // Note: this mutates the shared SiteAccess singleton in place (rather than only calling - // changeSiteAccess() below), because consumers that still read that singleton directly - // (e.g. the config resolver chain, for its MATCHING_TYPE_UNINITIALIZED early-access guard) - // never receive a SiteAccess through SiteAccessService and would otherwise never see it change. - $this->siteAccess->name = $event->getInput()->getParameterOption('--siteaccess', $this->defaultSiteAccessName); - $this->siteAccess->matchingType = 'cli'; + $siteAccessName = $event->getInput()->getParameterOption('--siteaccess', $this->defaultSiteAccessName); + $siteAccess = new SiteAccess($siteAccessName, 'cli'); - if (!$this->siteAccessProvider->isDefined($this->siteAccess->name)) { + if (!$this->siteAccessProvider->isDefined($siteAccess->name)) { throw new InvalidSiteAccessException( - $this->siteAccess->name, + $siteAccess->name, $this->siteAccessProvider, - $this->siteAccess->matchingType, + $siteAccess->matchingType, $this->debug ); } - $this->siteAccessService->changeSiteAccess($this->siteAccess); + $this->siteAccessService->changeSiteAccess($siteAccess); } public function setDebug($debug = false) diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index 748cc90d3f..78a441bd46 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -89,7 +89,6 @@ services: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - '@event_dispatcher' - - '@Ibexa\Core\MVC\Symfony\SiteAccess' tags: - { name: kernel.event_subscriber } diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 479a6cae47..aa80028535 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -74,7 +74,6 @@ services: arguments: - '%ibexa.site_access.default%' - '@ibexa.siteaccess.provider' - - '@Ibexa\Core\MVC\Symfony\SiteAccess' - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' - "%kernel.debug%" tags: diff --git a/src/contracts/Test/IbexaTestKernel.php b/src/contracts/Test/IbexaTestKernel.php index cae200d744..4b473592cf 100644 --- a/src/contracts/Test/IbexaTestKernel.php +++ b/src/contracts/Test/IbexaTestKernel.php @@ -17,6 +17,8 @@ use Ibexa\Contracts\Core\Persistence\TransactionHandler; use Ibexa\Contracts\Core\Repository; use Ibexa\Contracts\Core\Test\Persistence\Fixture\YamlFixture; +use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; use Ibexa\Tests\Integration\Core\IO\FlysystemTestAdapter; use Ibexa\Tests\Integration\Core\IO\FlysystemTestAdapterInterface; use JMS\TranslationBundle\JMSTranslationBundle; @@ -28,6 +30,7 @@ use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; @@ -168,6 +171,21 @@ public function registerContainerConfiguration(LoaderInterface $loader): void }); } + public function build(ContainerBuilder $container): void + { + parent::build($container); + + // Registered as a compiler pass (rather than done directly in registerContainerConfiguration()) + // because SiteAccessService's definition doesn't exist yet at that point: bundle extensions + // (which register it) only run once the container compiles. + $container->addCompilerPass(new class() implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + IbexaTestKernel::seedDefaultSiteAccess($container); + } + }); + } + /** * @throws \Exception */ @@ -270,6 +288,23 @@ private static function setUpTestLogger(ContainerBuilder $container): void $container->setDefinition('logger', new Definition(NullLogger::class)); } + /** + * Test cases using this kernel never dispatch a real request, so SiteAccessService would + * otherwise never have a current SiteAccess. Seed it the same way ConsoleCommandListener does + * for CLI: a fresh SiteAccess passed to changeSiteAccess(), not a shared/mutated singleton. + */ + public static function seedDefaultSiteAccess(ContainerBuilder $container): void + { + if (!$container->hasDefinition(SiteAccessService::class)) { + return; + } + + $container->getDefinition(SiteAccessService::class)->addMethodCall( + 'changeSiteAccess', + [new Definition(SiteAccess::class, ['default'])] + ); + } + /** * Creates synthetic services in container, allowing compilation of container when some services are missing. * Additionally, those services can be replaced with mock implementations at runtime, allowing integration testing. diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php index 0e50aa018f..9411580f31 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php @@ -25,23 +25,13 @@ class SiteAccessService implements SiteAccessServiceInterface, EventSubscriberInterface { /** @var \Ibexa\Core\MVC\Symfony\SiteAccess[] */ - private array $siteAccessStack; + private array $siteAccessStack = []; - /** - * @param \Ibexa\Core\MVC\Symfony\SiteAccess $siteAccess the shared, container-wide default - * SiteAccess, used as the initial stack entry until a real request (or an explicit - * changeSiteAccess() call) establishes the actual current one. This preserves - * getCurrent()'s pre-existing guarantee of never being null once the container is built, - * which code such as ComplexConfigProcessor relies on outside of an HTTP request cycle - * (CLI warm-up, integration tests, etc.). - */ public function __construct( private readonly SiteAccessProviderInterface $provider, private readonly ConfigResolverInterface $configResolver, - private readonly EventDispatcherInterface $eventDispatcher, - SiteAccess $siteAccess + private readonly EventDispatcherInterface $eventDispatcher ) { - $this->siteAccessStack = [$siteAccess]; } public static function getSubscribedEvents(): array @@ -94,7 +84,16 @@ public function getAll(): iterable public function getCurrent(): ?SiteAccess { - return $this->siteAccessStack !== [] ? end($this->siteAccessStack) : null; + if ($this->siteAccessStack === []) { + return null; + } + + // Defensive: a SiteAccess with an "uninitialized" matcher (see + // SiteAccess::MATCHING_TYPE_UNINITIALIZED) is not a real current SiteAccess even if it + // somehow ended up on the stack — surface it as null rather than a misleading value. + $current = end($this->siteAccessStack); + + return $current->matchingType !== SiteAccess::MATCHING_TYPE_UNINITIALIZED ? $current : null; } public function changeSiteAccess(SiteAccess $siteAccess): SiteAccess diff --git a/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php b/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php index f935db0537..d947d01f6c 100644 --- a/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php +++ b/tests/bundle/Core/EventListener/ConsoleCommandListenerTest.php @@ -25,9 +25,6 @@ class ConsoleCommandListenerTest extends TestCase { private const INVALID_SA_NAME = 'foo'; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - private $siteAccess; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ private $siteAccessService; @@ -46,12 +43,10 @@ class ConsoleCommandListenerTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->siteAccess = new SiteAccess('test'); $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); $this->listener = new ConsoleCommandListener( 'default', $this->getSiteAccessProviderMock(), - $this->siteAccess, $this->siteAccessService ); $this->inputDefinition = new InputDefinition([new InputOption('siteaccess', null, InputOption::VALUE_OPTIONAL)]); @@ -99,22 +94,20 @@ public function testValidSiteAccess() { $this->siteAccessService->expects(self::once()) ->method('changeSiteAccess') - ->with($this->siteAccess); + ->with(self::equalTo(new SiteAccess('site1', 'cli'))); $input = new ArrayInput(['--siteaccess' => 'site1'], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->onConsoleCommand($event); - self::assertEquals(new SiteAccess('site1', 'cli'), $this->siteAccess); } public function testDefaultSiteAccess() { $this->siteAccessService->expects(self::once()) ->method('changeSiteAccess') - ->with($this->siteAccess); + ->with(self::equalTo(new SiteAccess('default', 'cli'))); $input = new ArrayInput([], $this->inputDefinition); $event = new ConsoleCommandEvent($this->command, $input, $this->testOutput); $this->listener->onConsoleCommand($event); - self::assertEquals(new SiteAccess('default', 'cli'), $this->siteAccess); } private function getSiteAccessProviderMock(): SiteAccess\SiteAccessProviderInterface diff --git a/tests/integration/Core/Resources/settings/common.yml b/tests/integration/Core/Resources/settings/common.yml index d1d1d8d668..e293fe2755 100644 --- a/tests/integration/Core/Resources/settings/common.yml +++ b/tests/integration/Core/Resources/settings/common.yml @@ -106,13 +106,21 @@ services: Ibexa\Core\MVC\Symfony\SiteAccess: arguments: ['default', !php/const Ibexa\Core\MVC\Symfony\SiteAccess::MATCHING_TYPE_UNINITIALIZED] + # Dedicated SiteAccess used to seed SiteAccessService below: integration tests never dispatch a + # real request, so this establishes a default current SiteAccess the same way ConsoleCommandListener + # does for CLI (a fresh SiteAccess passed to changeSiteAccess(), no shared/mutated singleton). + ibexa.test.default_site_access: + class: Ibexa\Core\MVC\Symfony\SiteAccess + arguments: ['default'] + Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService: class: Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService arguments: - '@ibexa.siteaccess.provider' - '@ibexa.config.resolver' - '@Symfony\Component\EventDispatcher\EventDispatcher' - - '@Ibexa\Core\MVC\Symfony\SiteAccess' + calls: + - [changeSiteAccess, ['@ibexa.test.default_site_access']] Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface: alias: 'Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' diff --git a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php index 4fc4d986f8..821a6fae15 100644 --- a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php +++ b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php @@ -42,9 +42,6 @@ class SiteAccessServiceTest extends TestCase /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ private $siteAccess; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - private $defaultSiteAccess; - /** @var \ArrayIterator */ private $availableSiteAccesses; @@ -58,21 +55,28 @@ protected function setUp(): void $this->configResolver = $this->createMock(ConfigResolverInterface::class); $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->siteAccess = new SiteAccess('current'); - $this->defaultSiteAccess = new SiteAccess('uninitialized_default', SiteAccess::MATCHING_TYPE_UNINITIALIZED); $this->availableSiteAccesses = $this->getAvailableSitAccesses(['current', 'first_sa', 'second_sa', 'default']); $this->configResolverParameters = $this->getConfigResolverParameters(); } + public function testGetCurrentSiteAccessIsNullWhenStackIsEmpty(): void + { + $service = $this->createSiteAccessService(); + + self::assertNull($service->getCurrent()); + } + /** - * Before any request has matched, getCurrent() must return the constructor-seeded default - * SiteAccess rather than null — this preserves the pre-existing guarantee (relied on by e.g. - * ComplexConfigProcessor) that getCurrent() is never null once the container is built. + * A SiteAccess with an "uninitialized" matcher (see SiteAccess::MATCHING_TYPE_UNINITIALIZED) + * is not a real current SiteAccess — getCurrent() must surface it as null. */ - public function testGetCurrentSiteAccessReturnsSeedBeforeAnyMatch(): void + public function testGetCurrentSiteAccessIsNullWhenTopOfStackIsUninitialized(): void { $service = $this->createSiteAccessService(); - self::assertSame($this->defaultSiteAccess, $service->getCurrent()); + $service->changeSiteAccess(new SiteAccess('default', SiteAccess::MATCHING_TYPE_UNINITIALIZED)); + + self::assertNull($service->getCurrent()); } public function testGetCurrentSiteAccessAfterMainRequestMatch(): void @@ -96,8 +100,7 @@ public function testGetSiteAccess(): void $service = new SiteAccessService( $staticSiteAccessProvider, $this->createMock(ConfigResolverInterface::class), - $this->eventDispatcher, - $this->defaultSiteAccess + $this->eventDispatcher ); self::assertEquals( @@ -115,8 +118,7 @@ public function testGetSiteAccessThrowsNotFoundException(): void $service = new SiteAccessService( $staticSiteAccessProvider, $this->createMock(ConfigResolverInterface::class), - $this->eventDispatcher, - $this->defaultSiteAccess + $this->eventDispatcher ); $this->expectException(NotFoundException::class); @@ -295,8 +297,7 @@ private function createSiteAccessService(): SiteAccessService return new SiteAccessService( $this->provider, $this->configResolver, - $this->eventDispatcher, - $this->defaultSiteAccess + $this->eventDispatcher ); } From 255ca0b798bd0b579663685f3070351cbda15325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 12:13:41 +0200 Subject: [PATCH 06/11] IBX-12204: Mark broken Behat scenario after CLI SiteAccess BC break ConsoleCommandListener no longer mutates the shared SiteAccess singleton in place; it only calls SiteAccessService::changeSiteAccess() now. Any command that type-hints the shared SiteAccess service directly to read the CLI-resolved siteaccess (like the ibexa:behat:test-siteaccess fixture command used by this scenario) no longer sees it updated. This is an intentional, accepted BC break, documented in the PR description; tagging the scenario @broken until the fixture command migrates to SiteAccessServiceInterface::getCurrent(). --- src/bundle/Core/Features/Console/console.feature | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bundle/Core/Features/Console/console.feature b/src/bundle/Core/Features/Console/console.feature index 7fa780be8a..8076707797 100644 --- a/src/bundle/Core/Features/Console/console.feature +++ b/src/bundle/Core/Features/Console/console.feature @@ -1,4 +1,10 @@ Feature: app/console + # IBX-12204: ConsoleCommandListener no longer mutates the shared SiteAccess singleton in place + # (it only calls SiteAccessService::changeSiteAccess() now), so any command that reads the + # CLI-resolved siteaccess by type-hinting that shared service directly no longer sees it change. + # The ibexa:behat:test-siteaccess fixture command does exactly that; it needs to switch to + # SiteAccessServiceInterface::getCurrent() before this scenario can pass again. + @broken Scenario: Commands use the default siteaccess if not specified When I run a console script without specifying a siteaccess Then it is executed with the default one From 4c004d79bd74f283d0a26b5bdf2a5f506a35fbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 14:42:56 +0200 Subject: [PATCH 07/11] IBX-12204: Reference SiteAccessServiceInterface, not the concrete service Every DI reference to SiteAccessService (YAML service arguments, a Reference() in LegacyTestContainerBuilder, and ComplexConfigProcessor's constructor type-hint) now points at SiteAccessServiceInterface instead of the concrete class, consistent with how it's already injected elsewhere (e.g. SiteAccessLimitationType). The concrete class is only still referenced where structurally required: its own service definition, the interface alias's target, and IbexaTestKernel's compiler pass (Symfony aliases have no Definition object to fetch via getDefinition()/hasDefinition()) or a call to onSiteAccessMatch(), a subscriber-only method not part of the interface. --- src/bundle/Core/Resources/config/helpers.yml | 4 ++-- src/bundle/Core/Resources/config/image.yml | 2 +- src/bundle/Core/Resources/config/routing.yml | 2 +- src/bundle/Core/Resources/config/services.yml | 2 +- .../Core/SiteAccess/Config/ComplexConfigProcessor.php | 6 +++--- src/lib/Resources/settings/roles.yml | 2 +- .../bundle/Core/SiteAccess/Config/IOConfigResolverTest.php | 6 +++--- .../bundle/IO/DependencyInjection/IbexaIOExtensionTest.php | 4 ++++ tests/integration/Core/LegacyTestContainerBuilder.php | 4 ++-- 9 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/bundle/Core/Resources/config/helpers.yml b/src/bundle/Core/Resources/config/helpers.yml index 0888a9b550..9e154f1e8d 100644 --- a/src/bundle/Core/Resources/config/helpers.yml +++ b/src/bundle/Core/Resources/config/helpers.yml @@ -18,7 +18,7 @@ services: Ibexa\Core\Helper\ContentPreviewHelper: arguments: - '@Ibexa\Core\MVC\Symfony\SiteAccess\Router' - - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' Ibexa\Bundle\Core\EventListener\ConfigScopeListener: arguments: @@ -50,7 +50,7 @@ services: Ibexa\Bundle\Core\SiteAccess\Config\ComplexConfigProcessor: arguments: $configResolver: '@ibexa.config.resolver' - $siteAccessService: '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' + $siteAccessService: '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' Ibexa\Contracts\Core\SiteAccess\ConfigProcessor: alias: Ibexa\Bundle\Core\SiteAccess\Config\ComplexConfigProcessor diff --git a/src/bundle/Core/Resources/config/image.yml b/src/bundle/Core/Resources/config/image.yml index 3609cf381a..c26d10e462 100644 --- a/src/bundle/Core/Resources/config/image.yml +++ b/src/bundle/Core/Resources/config/image.yml @@ -68,7 +68,7 @@ services: - '@ibexa.cache_pool' - '@router.request_context' - '@Ibexa\Core\Persistence\Cache\Identifier\CacheIdentifierGeneratorInterface' - - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' ibexa.image_alias.imagine.alias_generator: class: Ibexa\Bundle\Core\Imagine\AliasGenerator diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index 78a441bd46..fb1366bcbe 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -54,7 +54,7 @@ services: - '@ibexa.api.repository' - "@router.default" - '@ibexa.config.resolver' - - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' - '%ibexa.urlalias_generator.charmap%' parent: Ibexa\Core\MVC\Symfony\Routing\Generator diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index aa80028535..b55c1a37df 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -74,7 +74,7 @@ services: arguments: - '%ibexa.site_access.default%' - '@ibexa.siteaccess.provider' - - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' - "%kernel.debug%" tags: - { name: kernel.event_subscriber } diff --git a/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php b/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php index f9279e167e..8ea2c66d60 100644 --- a/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php +++ b/src/bundle/Core/SiteAccess/Config/ComplexConfigProcessor.php @@ -13,7 +13,7 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\MVC\Exception\ParameterNotFoundException; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use function str_replace; final class ComplexConfigProcessor implements ConfigProcessor @@ -23,7 +23,7 @@ final class ComplexConfigProcessor implements ConfigProcessor /** @var \Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface */ private $configResolver; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService */ + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface */ private $siteAccessService; /** @var \Ibexa\Bundle\Core\DependencyInjection\Configuration\ComplexSettings\ComplexSettingParserInterface */ @@ -31,7 +31,7 @@ final class ComplexConfigProcessor implements ConfigProcessor public function __construct( ConfigResolverInterface $configResolver, - SiteAccessService $siteAccessService + SiteAccessServiceInterface $siteAccessService ) { $this->configResolver = $configResolver; $this->siteAccessService = $siteAccessService; diff --git a/src/lib/Resources/settings/roles.yml b/src/lib/Resources/settings/roles.yml index 57b59122de..062081dfe2 100644 --- a/src/lib/Resources/settings/roles.yml +++ b/src/lib/Resources/settings/roles.yml @@ -71,7 +71,7 @@ services: Ibexa\Core\Limitation\SiteAccessLimitationType: class: Ibexa\Core\Limitation\SiteAccessLimitationType - arguments: ['@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService'] + arguments: ['@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface'] tags: - {name: ibexa.permissions.limitation_type, alias: SiteAccess} diff --git a/tests/bundle/Core/SiteAccess/Config/IOConfigResolverTest.php b/tests/bundle/Core/SiteAccess/Config/IOConfigResolverTest.php index cf6b8a1586..b8d5416121 100644 --- a/tests/bundle/Core/SiteAccess/Config/IOConfigResolverTest.php +++ b/tests/bundle/Core/SiteAccess/Config/IOConfigResolverTest.php @@ -12,7 +12,7 @@ use Ibexa\Bundle\Core\SiteAccess\Config\IOConfigResolver; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\MVC\Symfony\SiteAccess; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use PHPUnit\Framework\TestCase; /** @@ -25,14 +25,14 @@ class IOConfigResolverTest extends TestCase /** @var \Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface|\PHPUnit\Framework\MockObject\MockObject */ private $configResolver; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService|\PHPUnit\Framework\MockObject\MockObject */ + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface|\PHPUnit\Framework\MockObject\MockObject */ private $siteAccessService; protected function setUp(): void { parent::setUp(); $this->configResolver = $this->createMock(ConfigResolverInterface::class); - $this->siteAccessService = $this->createMock(SiteAccessService::class); + $this->siteAccessService = $this->createMock(SiteAccessServiceInterface::class); } public function testGetUrlPrefix(): void diff --git a/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php b/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php index 0c6225ebc1..2f695f2af2 100644 --- a/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php +++ b/tests/bundle/IO/DependencyInjection/IbexaIOExtensionTest.php @@ -95,6 +95,10 @@ public function testUrlPrefixConfigurationIsUsedToDecorateUrl(): void // Simulate SiteAccess matching having happened for the "site" siteaccess, since // ComplexConfigProcessor now resolves the current SiteAccess via SiteAccessService // rather than reading a pre-set property. + // + // Fetched by concrete class (not SiteAccessServiceInterface): onSiteAccessMatch() is a + // subscriber-only method not part of the interface, and SetAllServicesPublicPass only + // exposes real service definitions, not the interface alias. $siteAccessService = $this->container->get(SiteAccessService::class); self::assertInstanceOf(SiteAccessService::class, $siteAccessService); $siteAccessService->onSiteAccessMatch( diff --git a/tests/integration/Core/LegacyTestContainerBuilder.php b/tests/integration/Core/LegacyTestContainerBuilder.php index 7d2e45f2dd..7038601452 100644 --- a/tests/integration/Core/LegacyTestContainerBuilder.php +++ b/tests/integration/Core/LegacyTestContainerBuilder.php @@ -13,7 +13,7 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigProcessor; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Container\Compiler; -use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Ibexa\Tests\Integration\Core\Repository\Container\Compiler\SetAllServicesPublicPass; use Symfony\Component\Cache\Adapter\RedisAdapter; use Symfony\Component\Config\FileLocator; @@ -166,7 +166,7 @@ private function registerSiteAccessConfigProcessingServices(): void $definition = new Definition(ComplexConfigProcessor::class); $definition->setArgument('$configResolver', new Reference(ConfigResolverInterface::class)); - $definition->setArgument('$siteAccessService', new Reference(SiteAccessService::class)); + $definition->setArgument('$siteAccessService', new Reference(SiteAccessServiceInterface::class)); $this->setDefinition(ConfigProcessor::class, $definition); } From f532a96f221b1eba0eb972811e7221942a954a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 16:33:07 +0200 Subject: [PATCH 08/11] IBX-12204: Fix DebugConfigResolverCommand's own CLI SiteAccess BC break Same bug as ibexa/behat's TestSiteaccessCommand: constructor-injected the bare, shared SiteAccess singleton to display the CLI-resolved siteaccess name, which ConsoleCommandListener no longer mutates in place. Switched to SiteAccessServiceInterface::getCurrent(), matching the pattern already used elsewhere in this PR. --- .../Core/Command/DebugConfigResolverCommand.php | 12 ++++++------ src/bundle/Core/Resources/config/commands.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bundle/Core/Command/DebugConfigResolverCommand.php b/src/bundle/Core/Command/DebugConfigResolverCommand.php index 8062d34248..ee8cd02155 100644 --- a/src/bundle/Core/Command/DebugConfigResolverCommand.php +++ b/src/bundle/Core/Command/DebugConfigResolverCommand.php @@ -8,7 +8,7 @@ namespace Ibexa\Bundle\Core\Command; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; -use Ibexa\Core\MVC\Symfony\SiteAccess; +use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -28,15 +28,14 @@ class DebugConfigResolverCommand extends Command /** @var \Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface */ private $configResolver; - /** @var \Ibexa\Core\MVC\Symfony\SiteAccess */ - private $siteAccess; + private SiteAccessServiceInterface $siteAccessService; public function __construct( ConfigResolverInterface $configResolver, - SiteAccess $siteAccess + SiteAccessServiceInterface $siteAccessService ) { $this->configResolver = $configResolver; - $this->siteAccess = $siteAccess; + $this->siteAccessService = $siteAccessService; parent::__construct(); } @@ -102,7 +101,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } - $output->writeln('SiteAccess name: ' . $this->siteAccess->name); + $siteAccess = $this->siteAccessService->getCurrent(); + $output->writeln('SiteAccess name: ' . ($siteAccess !== null ? $siteAccess->name : '')); $output->writeln('Parameter:'); $cloner = new VarCloner(); diff --git a/src/bundle/Core/Resources/config/commands.yml b/src/bundle/Core/Resources/config/commands.yml index f8d089acc5..87ebdf2176 100644 --- a/src/bundle/Core/Resources/config/commands.yml +++ b/src/bundle/Core/Resources/config/commands.yml @@ -12,7 +12,7 @@ services: class: Ibexa\Bundle\Core\Command\DebugConfigResolverCommand arguments: - '@ibexa.config.resolver' - - '@Ibexa\Core\MVC\Symfony\SiteAccess' + - '@Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface' tags: - { name: console.command } From ddf15d00e038ac984eebf1b718cd26fca958e36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 16:49:52 +0200 Subject: [PATCH 09/11] IBX-12204: Un-break console.feature siteaccess scenario The CLI SiteAccess BC break this scenario caught is now fixed on both known affected consumers: this repo's own DebugConfigResolverCommand, and ibexa/behat's TestSiteaccessCommand (fixed in a companion PR, cross-tested here via a temporary dependencies.json in the next commit). --- src/bundle/Core/Features/Console/console.feature | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/bundle/Core/Features/Console/console.feature b/src/bundle/Core/Features/Console/console.feature index 8076707797..7fa780be8a 100644 --- a/src/bundle/Core/Features/Console/console.feature +++ b/src/bundle/Core/Features/Console/console.feature @@ -1,10 +1,4 @@ Feature: app/console - # IBX-12204: ConsoleCommandListener no longer mutates the shared SiteAccess singleton in place - # (it only calls SiteAccessService::changeSiteAccess() now), so any command that reads the - # CLI-resolved siteaccess by type-hinting that shared service directly no longer sees it change. - # The ibexa:behat:test-siteaccess fixture command does exactly that; it needs to switch to - # SiteAccessServiceInterface::getCurrent() before this scenario can pass again. - @broken Scenario: Commands use the default siteaccess if not specified When I run a console script without specifying a siteaccess Then it is executed with the default one From 13045509512a753b2cf51f46a84b5002b80b0bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 7 Aug 2026 16:50:01 +0200 Subject: [PATCH 10/11] TEMPORARY: Added dependencies.json to test against ibexa/behat#191 Points Behat CI at the branch that fixes TestSiteaccessCommand, so this PR's "Commands use the default siteaccess if not specified" scenario can run and pass before that companion PR merges. Must be removed before merging this PR. --- dependencies.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 dependencies.json diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000000..b18c7f710a --- /dev/null +++ b/dependencies.json @@ -0,0 +1,11 @@ +{ + "recipesEndpoint": "", + "packages": [ + { + "requirement": "dev-IBX-12204-siteaccessservice-scope-stack as 6.0.x-dev", + "repositoryUrl": "https://github.com/ibexa/behat.git", + "package": "ibexa/behat", + "shouldBeAddedAsVCS": false + } + ] +} From d53626484531ae9bcef2b25ed42e6f39284d9266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Wed, 12 Aug 2026 13:27:46 +0200 Subject: [PATCH 11/11] IBX-12204: Guarantee preview scope restore runs on every exit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previewContentAction() only called restoreConfigScope()/setPreviewActive(false) on the success path; both catch blocks for the forwarded sub-request returned early, skipping them. This pre-existing gap didn't matter before, since SiteAccessService never tracked scope changes at all — but now that changeConfigScope() pushes onto its stack, skipping the restore leaves SiteAccessService and ConfigResolver disagreeing on the current scope: the stack silently unwinds on request finish (no CONFIG_SCOPE_RESTORE dispatch), so SiteAccessService::getCurrent() reverts while ConfigResolver, updated by ConfigScopeListener reacting to the earlier CONFIG_SCOPE_CHANGE, stays on the preview scope. Wrapped the sub-request handling in try/finally so both calls always run, regardless of how the method exits. Calling restoreConfigScope() even when the scope was never changed is safe: restoreSiteAccess()'s floor guard never pops past the request-matched entry, so it's a no-op in that case (matching the pre-existing test expectations, which already assumed an unconditional call). Flagged by GitHub Copilot's review on PR #798. --- .../Controller/Content/PreviewController.php | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/lib/MVC/Symfony/Controller/Content/PreviewController.php b/src/lib/MVC/Symfony/Controller/Content/PreviewController.php index aa464234ca..193f0ff08f 100644 --- a/src/lib/MVC/Symfony/Controller/Content/PreviewController.php +++ b/src/lib/MVC/Symfony/Controller/Content/PreviewController.php @@ -131,33 +131,41 @@ public function previewContentAction( } try { - $viewType = $request->query->get('viewType', ViewManagerInterface::VIEW_TYPE_FULL); - $response = $this->kernel->handle( - $this->getForwardRequest($location, $content, $siteAccess, $request, $language, $viewType), - HttpKernelInterface::SUB_REQUEST, - false - ); - } catch (APINotFoundException $e) { - $message = sprintf('Location (%s) not found or not available in requested language (%s)', $location->id, $language); - $this->logger->warning( - sprintf('%s %s', $message, 'when loading the preview page'), - ['exception' => $e] - ); - if ($this->debugMode) { - throw new BadStateException('Preview page', $message, $e); + try { + $viewType = $request->query->get('viewType', ViewManagerInterface::VIEW_TYPE_FULL); + $response = $this->kernel->handle( + $this->getForwardRequest($location, $content, $siteAccess, $request, $language, $viewType), + HttpKernelInterface::SUB_REQUEST, + false + ); + } catch (APINotFoundException $e) { + $message = sprintf('Location (%s) not found or not available in requested language (%s)', $location->id, $language); + $this->logger->warning( + sprintf('%s %s', $message, 'when loading the preview page'), + ['exception' => $e] + ); + if ($this->debugMode) { + throw new BadStateException('Preview page', $message, $e); + } + + return new Response($message); + } catch (Exception $e) { + return $this->buildResponseForGenericPreviewError($location, $content, $e); } - - return new Response($message); - } catch (Exception $e) { - return $this->buildResponseForGenericPreviewError($location, $content, $e); + $response->headers->addCacheControlDirective('no-cache', true); + $response->setPrivate(); + + return $response; + } finally { + // Guarantee the scope is restored and the preview flag is cleared however this method + // exits (success, a caught exception's early return, or an uncaught one) — otherwise + // SiteAccessService and ConfigResolver's scope can be left disagreeing once the request + // finishes and the stack silently unwinds without a CONFIG_SCOPE_RESTORE. Safe to call + // unconditionally even when the scope was never changed above: restoreSiteAccess()'s + // floor guard never pops past the request-matched entry, so it's a no-op in that case. + $this->previewHelper->restoreConfigScope(); + $this->previewHelper->setPreviewActive(false); } - $response->headers->addCacheControlDirective('no-cache', true); - $response->setPrivate(); - - $this->previewHelper->restoreConfigScope(); - $this->previewHelper->setPreviewActive(false); - - return $response; } /**