diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index e74fe3208d..3f89734784 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -90,6 +90,8 @@ services: - '@ibexa.config.resolver' calls: - [setSiteAccess, ['@Ibexa\Core\MVC\Symfony\SiteAccess']] + tags: + - { name: kernel.event_subscriber } Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface: alias: 'Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessService' diff --git a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php index 0676707544..9780b0deab 100644 --- a/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php +++ b/src/lib/MVC/Symfony/SiteAccess/SiteAccessService.php @@ -11,12 +11,16 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; +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\EventSubscriberInterface; -class SiteAccessService implements SiteAccessServiceInterface, SiteAccessAware +class SiteAccessService implements SiteAccessServiceInterface, SiteAccessAware, EventSubscriberInterface { - private ?SiteAccess $siteAccess = null; + /** @var \Ibexa\Core\MVC\Symfony\SiteAccess[] */ + private array $siteAccessStack = []; public function __construct( private readonly SiteAccessProviderInterface $provider, @@ -24,9 +28,40 @@ public function __construct( ) { } + public static function getSubscribedEvents(): array + { + return [ + MVCEvents::CONFIG_SCOPE_CHANGE => 'onConfigScopeChange', + MVCEvents::CONFIG_SCOPE_RESTORE => 'onConfigScopeRestore', + ]; + } + public function setSiteAccess(?SiteAccess $siteAccess = null): void { - $this->siteAccess = $siteAccess; + $this->siteAccessStack = $siteAccess !== null ? [$siteAccess] : []; + } + + /** + * Pushes the new SiteAccess onto the stack, so that getCurrent() reflects it until the + * matching restore happens. + */ + public function onConfigScopeChange(ScopeChangeEvent $event): void + { + $this->siteAccessStack[] = $event->getSiteAccess(); + } + + /** + * Pops the SiteAccess pushed by the matching onConfigScopeChange(), but never below one + * remaining entry: the bottom-most entry (the current request's SiteAccess) must survive an + * unbalanced restore. + */ + public function onConfigScopeRestore(ScopeChangeEvent $event): void + { + if (count($this->siteAccessStack) <= 1) { + return; + } + + array_pop($this->siteAccessStack); } public function exists(string $name): bool @@ -50,12 +85,12 @@ public function getAll(): iterable public function getCurrent(): ?SiteAccess { - return $this->siteAccess ?? null; + return $this->siteAccessStack !== [] ? end($this->siteAccessStack) : null; } public function getSiteAccessesRelation(?SiteAccess $siteAccess = null): array { - $siteAccess = $siteAccess ?? $this->siteAccess; + $siteAccess = $siteAccess ?? $this->getCurrent(); if ($siteAccess === null) { throw new InvalidArgumentException('siteAccess', 'no SiteAccess given and none currently set'); } diff --git a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php index 7ef230d7ee..4a79059821 100644 --- a/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php +++ b/tests/lib/MVC/Symfony/SiteAccess/SiteAccessServiceTest.php @@ -11,6 +11,8 @@ use ArrayIterator; use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; +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; @@ -65,6 +67,91 @@ public function testGetCurrentSiteAccess(): void self::assertNull($service->getCurrent()); } + public function testGetSubscribedEvents(): void + { + self::assertSame( + [ + MVCEvents::CONFIG_SCOPE_CHANGE => 'onConfigScopeChange', + MVCEvents::CONFIG_SCOPE_RESTORE => 'onConfigScopeRestore', + ], + SiteAccessService::getSubscribedEvents() + ); + } + + public function testOnConfigScopeChangeMakesGetCurrentReflectTheNewSiteAccess(): void + { + $service = new SiteAccessService( + $this->createMock(SiteAccessProviderInterface::class), + $this->createMock(ConfigResolverInterface::class) + ); + + $baseSiteAccess = new SiteAccess('base'); + $service->setSiteAccess($baseSiteAccess); + + $previewSiteAccess = new SiteAccess('preview'); + $service->onConfigScopeChange(new ScopeChangeEvent($previewSiteAccess)); + + self::assertSame($previewSiteAccess, $service->getCurrent()); + } + + public function testOnConfigScopeRestoreBringsBackThePreviousSiteAccess(): void + { + $service = new SiteAccessService( + $this->createMock(SiteAccessProviderInterface::class), + $this->createMock(ConfigResolverInterface::class) + ); + + $baseSiteAccess = new SiteAccess('base'); + $service->setSiteAccess($baseSiteAccess); + + $previewSiteAccess = new SiteAccess('preview'); + $service->onConfigScopeChange(new ScopeChangeEvent($previewSiteAccess)); + $service->onConfigScopeRestore(new ScopeChangeEvent($baseSiteAccess)); + + self::assertSame($baseSiteAccess, $service->getCurrent()); + } + + public function testOnConfigScopeRestoreNeverDropsTheBaseSiteAccess(): void + { + $service = new SiteAccessService( + $this->createMock(SiteAccessProviderInterface::class), + $this->createMock(ConfigResolverInterface::class) + ); + + $baseSiteAccess = new SiteAccess('base'); + $service->setSiteAccess($baseSiteAccess); + + $service->onConfigScopeRestore(new ScopeChangeEvent($baseSiteAccess)); + + self::assertSame($baseSiteAccess, $service->getCurrent()); + } + + public function testNestedConfigScopeChangesAndRestoresRoundTripLikeAStack(): void + { + $service = new SiteAccessService( + $this->createMock(SiteAccessProviderInterface::class), + $this->createMock(ConfigResolverInterface::class) + ); + + $baseSiteAccess = new SiteAccess('base'); + $service->setSiteAccess($baseSiteAccess); + + $firstSiteAccess = new SiteAccess('first'); + $secondSiteAccess = new SiteAccess('second'); + + $service->onConfigScopeChange(new ScopeChangeEvent($firstSiteAccess)); + self::assertSame($firstSiteAccess, $service->getCurrent()); + + $service->onConfigScopeChange(new ScopeChangeEvent($secondSiteAccess)); + self::assertSame($secondSiteAccess, $service->getCurrent()); + + $service->onConfigScopeRestore(new ScopeChangeEvent($firstSiteAccess)); + self::assertSame($firstSiteAccess, $service->getCurrent()); + + $service->onConfigScopeRestore(new ScopeChangeEvent($baseSiteAccess)); + self::assertSame($baseSiteAccess, $service->getCurrent()); + } + public function testGetSiteAccess(): void { $staticSiteAccessProvider = new StaticSiteAccessProvider(