Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/bundle/Core/Resources/config/routing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ services:
tags:
- { name: kernel.event_subscriber }

ibexa.siteaccess_restore_listener:
class: Ibexa\Core\MVC\Symfony\EventListener\SiteAccessRestoreListener
arguments:
$requestStack: '@request_stack'
$eventDispatcher: '@event_dispatcher'
tags:
- { name: kernel.event_subscriber }

Ibexa\Core\MVC\Symfony\Component\Serializer\CompoundMatcherNormalizer:
autoconfigure: false

Expand Down
12 changes: 12 additions & 0 deletions src/lib/MVC/Symfony/Component/Serializer/URIElementNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ public function supportsNormalization($data, ?string $format = null)
return $data instanceof URIElement;
}

public function normalize($object, ?string $format = null, array $context = [])
{
// Trigger lazy initialization of URIElement::$uriElements from the matched request,
// so the serialized representation stays usable after deserialization, where
// the request is not available (IBX-12102).
if ($object instanceof URIElement) {
$object->match();
}

return parent::normalize($object, $format, $context);
}

/**
* @see \Ibexa\Core\MVC\Symfony\SiteAccess\Matcher\URIElement::__sleep
*/
Expand Down
69 changes: 69 additions & 0 deletions src/lib/MVC/Symfony/EventListener/SiteAccessRestoreListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Core\MVC\Symfony\EventListener;

use Ibexa\Core\MVC\Symfony\Event\PostSiteAccessMatchEvent;
use Ibexa\Core\MVC\Symfony\MVCEvents;
use Ibexa\Core\MVC\Symfony\SiteAccess;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Restores the parent request's SiteAccess when a sub-request finishes, by re-dispatching
* MVCEvents::SITEACCESS — its listeners mutate shared state for every sub-request. Mirrors
* {@see \Symfony\Component\HttpKernel\EventListener\RouterListener::onKernelFinishRequest()}.
*/
final class SiteAccessRestoreListener implements EventSubscriberInterface
{
private RequestStack $requestStack;

private EventDispatcherInterface $eventDispatcher;

public function __construct(
RequestStack $requestStack,
EventDispatcherInterface $eventDispatcher
) {
$this->requestStack = $requestStack;
$this->eventDispatcher = $eventDispatcher;
}

public static function getSubscribedEvents(): array
{
return [
KernelEvents::FINISH_REQUEST => ['onKernelFinishRequest', 0],
];
}

public function onKernelFinishRequest(FinishRequestEvent $event): void
{
if ($event->isMainRequest()) {
return;
}

// The finishing request is still on the stack, so this is the request control returns to
$parentRequest = $this->requestStack->getParentRequest();
if ($parentRequest === null) {
return;
}

$siteAccess = $parentRequest->attributes->get('siteaccess');
if (!$siteAccess instanceof SiteAccess) {
return;
}

$this->eventDispatcher->dispatch(
new PostSiteAccessMatchEvent($siteAccess, $parentRequest, HttpKernelInterface::SUB_REQUEST),
MVCEvents::SITEACCESS
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@

final class URIElementNormalizerTest extends TestCase
{
public function testNormalization(): void
/**
* @dataProvider provideForTestNormalization
*/
public function testNormalization(bool $initializeUriElements): void
{
$normalizer = new URIElementNormalizer();
$normalizer->setSerializer(new SerializerStub());

$matcher = new URIElement(2);
// Set request and invoke match to initialize HostElement::$hostElements
$matcher->setRequest(SimplifiedRequest::fromUrl('http://ezpublish.dev/foo/bar'));
$matcher->match();
$matcher->setRequest(SimplifiedRequest::fromUrl('https://ibexa.dev/foo/bar'));
if ($initializeUriElements) {
$matcher->match();
}

$this->assertEquals(
[
Expand All @@ -36,6 +40,16 @@ public function testNormalization(): void
);
}

/**
* @return iterable<string, array{bool}>
*/
public static function provideForTestNormalization(): iterable
{
yield 'uriElements initialized by match()' => [true];
// uriElements must be computed from the request during normalization (IBX-12102)
yield 'uriElements not yet initialized' => [false];
}

public function testSupportsNormalization(): void
{
$normalizer = new URIElementNormalizer();
Expand Down
135 changes: 135 additions & 0 deletions tests/lib/MVC/Symfony/EventListener/SiteAccessRestoreListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Core\MVC\Symfony\EventListener;

use Ibexa\Core\MVC\Symfony\Event\PostSiteAccessMatchEvent;
use Ibexa\Core\MVC\Symfony\EventListener\SiteAccessRestoreListener;
use Ibexa\Core\MVC\Symfony\MVCEvents;
use Ibexa\Core\MVC\Symfony\SiteAccess;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\FinishRequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* @covers \Ibexa\Core\MVC\Symfony\EventListener\SiteAccessRestoreListener
*/
final class SiteAccessRestoreListenerTest extends TestCase
{
/** @var \PHPUnit\Framework\MockObject\MockObject&\Symfony\Component\HttpKernel\HttpKernelInterface */
private HttpKernelInterface $kernel;

/** @var \PHPUnit\Framework\MockObject\MockObject&\Symfony\Component\EventDispatcher\EventDispatcherInterface */
private EventDispatcherInterface $eventDispatcher;

private RequestStack $requestStack;

private SiteAccessRestoreListener $listener;

protected function setUp(): void
{
parent::setUp();
$this->kernel = $this->createMock(HttpKernelInterface::class);
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$this->requestStack = new RequestStack();
$this->listener = new SiteAccessRestoreListener(
$this->requestStack,
$this->eventDispatcher
);
}

public function testGetSubscribedEvents(): void
{
self::assertSame(
[KernelEvents::FINISH_REQUEST => ['onKernelFinishRequest', 0]],
SiteAccessRestoreListener::getSubscribedEvents()
);
}

public function testRestoresParentSiteAccessWhenSubRequestFinishes(): void
{
$parentSiteAccess = new SiteAccess('site_fr', 'uri:element');
$parentRequest = self::createRequestWithSiteAccess($parentSiteAccess);
$subRequest = self::createRequestWithSiteAccess(new SiteAccess('admin', 'uri:element'));

$this->requestStack->push($parentRequest);
$this->requestStack->push($subRequest);

$this->eventDispatcher
->expects(self::once())
->method('dispatch')
->with(
self::callback(
static function (PostSiteAccessMatchEvent $event) use ($parentSiteAccess, $parentRequest): bool {
return $event->getSiteAccess() === $parentSiteAccess
&& $event->getRequest() === $parentRequest
&& $event->getRequestType() === HttpKernelInterface::SUB_REQUEST;
}
),
MVCEvents::SITEACCESS
);

$this->listener->onKernelFinishRequest(
new FinishRequestEvent($this->kernel, $subRequest, HttpKernelInterface::SUB_REQUEST)
);
}

/**
* @dataProvider provideNoDispatchCases
*
* @param \Symfony\Component\HttpFoundation\Request[] $requests
*/
public function testNoDispatch(array $requests, int $requestType): void
{
$finishingRequest = null;
foreach ($requests as $request) {
$this->requestStack->push($request);
$finishingRequest = $request;
}
self::assertNotNull($finishingRequest);

$this->eventDispatcher->expects(self::never())->method('dispatch');

$this->listener->onKernelFinishRequest(
new FinishRequestEvent($this->kernel, $finishingRequest, $requestType)
);
}

/**
* @return iterable<string, array{\Symfony\Component\HttpFoundation\Request[], int}>
*/
public static function provideNoDispatchCases(): iterable
{
yield 'main request' => [
[self::createRequestWithSiteAccess(new SiteAccess('site'))],
HttpKernelInterface::MAIN_REQUEST,
];

yield 'sub-request without parent request' => [
[Request::create('/_fragment')],
HttpKernelInterface::SUB_REQUEST,
];

yield 'parent request without siteaccess attribute' => [
[Request::create('/'), Request::create('/_fragment')],
HttpKernelInterface::SUB_REQUEST,
];
}

private static function createRequestWithSiteAccess(SiteAccess $siteAccess): Request
{
$request = Request::create('/');
$request->attributes->set('siteaccess', $siteAccess);

return $request;
}
}