diff --git a/composer.json b/composer.json index 46f22641f1..529f143c68 100644 --- a/composer.json +++ b/composer.json @@ -47,13 +47,13 @@ "symfony/http-foundation": "^7.4", "symfony/http-kernel": "^7.4", "symfony/mime": "^7.4", + "symfony/options-resolver": "^7.4", "symfony/polyfill-php80": "^1.27", "symfony/process": "^7.4", "symfony/security-bundle": "^7.4", "symfony/security-core": "^7.4", "symfony/security-http": "^7.4", "symfony/serializer": "^7.4", - "symfony/templating": "^6.4.0", "symfony/translation": "^7.4", "symfony/validator": "^7.4", "symfony/var-dumper": "^7.4", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 25372ad367..4e72c2e967 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2742,12 +2742,6 @@ parameters: count: 1 path: src/bundle/Core/Matcher/ServiceAwareMatcherFactory.php - - - message: '#^Class Ibexa\\Bundle\\Core\\Routing\\DefaultRouter extends @final class Symfony\\Bundle\\FrameworkBundle\\Routing\\Router\.$#' - identifier: class.extendsFinalByPhpDoc - count: 1 - path: src/bundle/Core/Routing/DefaultRouter.php - - message: '#^Cannot access property \$name on Ibexa\\Core\\MVC\\Symfony\\SiteAccess\|null\.$#' identifier: property.nonObject @@ -7086,12 +7080,6 @@ parameters: count: 1 path: src/lib/MVC/Symfony/Controller/Controller.php - - - message: '#^Method Ibexa\\Core\\MVC\\Symfony\\Controller\\Controller\:\:getTemplateEngine\(\) should return Symfony\\Component\\Templating\\EngineInterface but returns object\.$#' - identifier: return.type - count: 1 - path: src/lib/MVC/Symfony/Controller/Controller.php - - message: '#^Method Ibexa\\Core\\MVC\\Symfony\\Controller\\Controller\:\:render\(\) has parameter \$parameters with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -21420,82 +21408,16 @@ parameters: count: 2 path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:getExpectedRequestContext\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:getExpectedRequestContext\(\) has parameter \$uri with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:providerGenerateNoSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:providerGenerateWithSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testGenerateNoSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testGenerateNoSiteAccess\(\) has parameter \$url with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testGenerateReverseSiteAccessMatch\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testGenerateWithSiteAccess\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testGetContextBySimplifiedRequest\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testMatchRequestRegularPathinfo\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - - - message: '#^Method Ibexa\\Tests\\Bundle\\Core\\Routing\\DefaultRouterTest\:\:testMatchRequestWithSemanticPathinfo\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: tests/bundle/Core/Routing/DefaultRouterTest.php - - message: '#^Offset ''scheme'' might not exist on array\{scheme\?\: string, host\: string, port\: int\<0, 65535\>, user\?\: string, pass\?\: string, path\?\: string, query\?\: string, fragment\?\: string\}\.$#' identifier: offsetAccess.notFound - count: 2 + count: 1 path: tests/bundle/Core/Routing/DefaultRouterTest.php - message: '#^Offset ''scheme'' might not exist on array\{scheme\?\: string, host\: string, port\?\: int\<0, 65535\>, user\?\: string, pass\?\: string, path\?\: string, query\?\: string, fragment\?\: string\}\.$#' identifier: offsetAccess.notFound - count: 2 + count: 1 path: tests/bundle/Core/Routing/DefaultRouterTest.php - diff --git a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php index 76d9769a08..b9ef758fde 100644 --- a/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php +++ b/src/bundle/Core/DependencyInjection/Compiler/ChainRoutingPass.php @@ -8,8 +8,6 @@ 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 Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -25,19 +23,10 @@ public function process(ContainerBuilder $container): void $chainRouter = $container->getDefinition(ChainRouter::class); // Enforce default router to be part of the routing chain - // The default router will be given the highest priority so that it will be used by default + // The default router will be given the highest priority so that it will be used by default. + // The SiteAccess-aware behavior is provided by \Ibexa\Bundle\Core\Routing\DefaultRouter decorating router.default. if ($container->hasDefinition('router.default')) { $defaultRouter = $container->getDefinition('router.default'); - $defaultRouter->addMethodCall('setSiteAccess', [new Reference(SiteAccess::class)]); - $defaultRouter->addMethodCall('setConfigResolver', [new Reference('ibexa.config.resolver')]); - $defaultRouter->addMethodCall( - 'setNonSiteAccessAwareRoutes', - ['%ibexa.default_router.non_site_access_aware_routes%'] - ); - $defaultRouter->addMethodCall( - 'setSiteAccessRouter', - [new Reference(Router::class)] - ); if (!$defaultRouter->hasTag('router')) { $defaultRouter->addTag( 'router', diff --git a/src/bundle/Core/DependencyInjection/Compiler/RouterPass.php b/src/bundle/Core/DependencyInjection/Compiler/RouterPass.php deleted file mode 100644 index 27874182ec..0000000000 --- a/src/bundle/Core/DependencyInjection/Compiler/RouterPass.php +++ /dev/null @@ -1,26 +0,0 @@ -hasDefinition('router.default')) { - return; - } - - $container - ->findDefinition('router.default') - ->setClass(DefaultRouter::class); - } -} diff --git a/src/bundle/Core/IbexaCoreBundle.php b/src/bundle/Core/IbexaCoreBundle.php index 2904692d85..9049d661bb 100644 --- a/src/bundle/Core/IbexaCoreBundle.php +++ b/src/bundle/Core/IbexaCoreBundle.php @@ -24,7 +24,6 @@ use Ibexa\Bundle\Core\DependencyInjection\Compiler\RegisterSearchEngineIndexerPass; use Ibexa\Bundle\Core\DependencyInjection\Compiler\RegisterSearchEnginePass; use Ibexa\Bundle\Core\DependencyInjection\Compiler\RegisterStorageEnginePass; -use Ibexa\Bundle\Core\DependencyInjection\Compiler\RouterPass; use Ibexa\Bundle\Core\DependencyInjection\Compiler\SecurityPass; use Ibexa\Bundle\Core\DependencyInjection\Compiler\SessionConfigurationPass; use Ibexa\Bundle\Core\DependencyInjection\Compiler\SiteAccessMatcherRegistryPass; @@ -69,7 +68,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new RegisterSearchEngineIndexerPass()); $container->addCompilerPass(new AggregateFieldValueMapperPass()); $container->addCompilerPass(new FieldRegistryPass()); - $container->addCompilerPass(new RouterPass()); $container->addCompilerPass(new SecurityPass()); $container->addCompilerPass(new FragmentPass()); $container->addCompilerPass(new StorageConnectionPass()); diff --git a/src/bundle/Core/Imagine/Filter/UnsupportedFilter.php b/src/bundle/Core/Imagine/Filter/UnsupportedFilter.php index 2b6cfd1f48..8ce5359216 100644 --- a/src/bundle/Core/Imagine/Filter/UnsupportedFilter.php +++ b/src/bundle/Core/Imagine/Filter/UnsupportedFilter.php @@ -15,7 +15,7 @@ class UnsupportedFilter extends AbstractFilter /** * @throws \Imagine\Exception\NotSupportedException */ - public function apply(ImageInterface $image) + public function apply(ImageInterface $image): ImageInterface { throw new NotSupportedException('The filter is not supported by your current configuration.'); } diff --git a/src/bundle/Core/Resources/config/routing.yml b/src/bundle/Core/Resources/config/routing.yml index 59f463f70e..1e0fbe170d 100644 --- a/src/bundle/Core/Resources/config/routing.yml +++ b/src/bundle/Core/Resources/config/routing.yml @@ -11,6 +11,20 @@ parameters: ">" : "%3E" services: + ibexa.routing.default_router: + class: Ibexa\Bundle\Core\Routing\DefaultRouter + decorates: router.default + decoration_on_invalid: ignore + arguments: + $innerRouter: '@.inner' + $siteAccessRouter: '@Ibexa\Core\MVC\Symfony\SiteAccess\Router' + $nonSiteAccessAwareRoutes: '%ibexa.default_router.non_site_access_aware_routes%' + $logger: '@?logger' + calls: + - [setSiteAccess, ['@?Ibexa\Core\MVC\Symfony\SiteAccess']] + tags: + - { name: monolog.logger, channel: router } + Ibexa\Core\MVC\Symfony\Routing\ChainRouter: class: Ibexa\Core\MVC\Symfony\Routing\ChainRouter arguments: ["@?logger"] diff --git a/src/bundle/Core/Routing/DefaultRouter.php b/src/bundle/Core/Routing/DefaultRouter.php index b8999d2460..da3000e2e3 100644 --- a/src/bundle/Core/Routing/DefaultRouter.php +++ b/src/bundle/Core/Routing/DefaultRouter.php @@ -4,63 +4,76 @@ * @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\Bundle\Core\Routing; -use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; 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\URILexer; -use Symfony\Bundle\FrameworkBundle\Routing\Router; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Exception\RouteNotFoundException; +use Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface; +use Symfony\Component\Routing\Matcher\RequestMatcherInterface; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; /** - * Extension of Symfony default router implementing RequestMatcherInterface. + * SiteAccess-aware decorator of the Symfony router. + * + * Matching honours the `semanticPathinfo` request attribute set by the SiteAccess matcher, and link generation + * prepends the SiteAccess URI part (for URI-based SiteAccess matchers) and supports the `siteaccess` route parameter. */ -class DefaultRouter extends Router implements SiteAccessAware +final class DefaultRouter implements RouterInterface, RequestMatcherInterface, WarmableInterface, SiteAccessAware { - protected ?SiteAccess $siteAccess = null; + private ?SiteAccess $siteAccess = null; - /** @var string[] */ - protected array $nonSiteAccessAwareRoutes = []; - - protected ConfigResolverInterface $configResolver; + /** + * @param string[] $nonSiteAccessAwareRoutes route name prefixes that are not supposed to be SiteAccess aware, + * i.e. routes pointing to asset generation + */ + public function __construct( + private readonly RouterInterface&RequestMatcherInterface $innerRouter, + private readonly SiteAccessRouterInterface $siteAccessRouter, + private readonly array $nonSiteAccessAwareRoutes = [], + private readonly ?LoggerInterface $logger = null + ) { + } - protected SiteAccessRouterInterface $siteAccessRouter; + public function setSiteAccess(?SiteAccess $siteAccess = null): void + { + $this->siteAccess = $siteAccess; + } - public function setConfigResolver(ConfigResolverInterface $configResolver): void + public function setContext(RequestContext $context): void { - $this->configResolver = $configResolver; + $this->innerRouter->setContext($context); } - public function setSiteAccess(?SiteAccess $siteAccess = null): void + public function getContext(): RequestContext { - $this->siteAccess = $siteAccess; + return $this->innerRouter->getContext(); } - /** - * Injects route names that are not supposed to be SiteAccess aware. - * i.e. Routes pointing to asset generation (like assetic). - * - * @param string[] $routes - */ - public function setNonSiteAccessAwareRoutes(array $routes): void + public function getRouteCollection(): RouteCollection { - $this->nonSiteAccessAwareRoutes = $routes; + return $this->innerRouter->getRouteCollection(); } - public function setSiteAccessRouter(SiteAccessRouterInterface $siteAccessRouter): void + /** + * @return array + */ + public function match(string $pathinfo): array { - $this->siteAccessRouter = $siteAccessRouter; + return $this->innerRouter->match($pathinfo); } /** - * @return array An array of parameters + * @return array */ public function matchRequest(Request $request): array { @@ -72,7 +85,7 @@ public function matchRequest(Request $request): array ); } - return parent::matchRequest($request); + return $this->innerRouter->matchRequest($request); } /** @@ -91,53 +104,77 @@ public function generate(string $name, array $parameters = [], int $referenceTyp // Switch request context for link generation. $context = $this->getContextBySimplifiedRequest($siteAccess->matcher->getRequest()); $this->setContext($context); - } elseif ($this->logger) { + } else { $siteAccess = $this->siteAccess; - $this->logger->notice("Could not generate a link using provided 'siteaccess' parameter: {$parameters['siteaccess']}. Generating using current context."); + $this->logger?->notice("Could not generate a link using provided 'siteaccess' parameter: {$parameters['siteaccess']}. Generating using current context."); } unset($parameters['siteaccess']); } try { - $url = parent::generate($name, $parameters, $referenceType); - } catch (RouteNotFoundException $e) { - // Switch back to original context, for next links generation. + $url = $this->innerRouter->generate($name, $parameters, $referenceType); + + // Now putting back SiteAccess URI if needed. + if ($isSiteAccessAware && $siteAccess !== null && $siteAccess->matcher instanceof URILexer) { + $url = $this->prependSiteAccessUri($url, $context, $referenceType, $siteAccess->matcher); + } + + return $url; + } finally { + // Switch back to original context, for next links generation, including when generation fails. $this->setContext($originalContext); - throw $e; } + } - // Now putting back SiteAccess URI if needed. - if ($isSiteAccessAware && $siteAccess && $siteAccess->matcher instanceof URILexer) { - if ($referenceType === self::ABSOLUTE_URL || $referenceType === self::NETWORK_PATH) { - $scheme = $context->getScheme(); - $port = ''; - if ($scheme === 'http' && $this->context->getHttpPort() !== 80) { - $port = ':' . $this->context->getHttpPort(); - } elseif ($scheme === 'https' && $this->context->getHttpsPort() !== 443) { - $port = ':' . $this->context->getHttpsPort(); - } - - $base = $context->getHost() . $port . $context->getBaseUrl(); - } else { - $base = $context->getBaseUrl(); + private function prependSiteAccessUri(string $url, RequestContext $context, int $referenceType, URILexer $matcher): string + { + if ($referenceType === self::ABSOLUTE_URL || $referenceType === self::NETWORK_PATH) { + $scheme = $context->getScheme(); + $port = ''; + if ($scheme === 'http' && $context->getHttpPort() !== 80) { + $port = ':' . $context->getHttpPort(); + } elseif ($scheme === 'https' && $context->getHttpsPort() !== 443) { + $port = ':' . $context->getHttpsPort(); } - $linkUri = $base ? substr($url, strpos($url, $base) + strlen($base)) : $url; - $url = str_replace($linkUri, $siteAccess->matcher->analyseLink($linkUri), $url); + $base = $context->getHost() . $port . $context->getBaseUrl(); + } else { + $base = $context->getBaseUrl(); + } + + $linkUri = $base ? substr($url, strpos($url, $base) + strlen($base)) : $url; + + return str_replace($linkUri, $matcher->analyseLink($linkUri), $url); + } + + /** + * @return string[] + */ + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if ($this->innerRouter instanceof WarmableInterface) { + return $this->innerRouter->warmUp($cacheDir, $buildDir); } - // Switch back to original context, for next links generation. - $this->setContext($originalContext); + return []; + } - return $url; + /** + * Merges context from $simplifiedRequest into a clone of the current context. + */ + private function getContextBySimplifiedRequest(SimplifiedRequest $simplifiedRequest): RequestContext + { + // Instantiated per call on purpose: the factory clones the current context and mutates that clone, + // so it is per-call state and cannot be a shared service. + return (new RequestContextFactory($this->getContext()))->getContextBySimplifiedRequest($simplifiedRequest); } /** * Checks if $routeName is a siteAccess aware route, and thus needs to have siteAccess URI prepended. * Will be used for link generation, only in the case of URI SiteAccess matching. */ - protected function isSiteAccessAwareRoute(string $routeName): bool + private function isSiteAccessAwareRoute(string $routeName): bool { foreach ($this->nonSiteAccessAwareRoutes as $ignoredPrefix) { if (str_starts_with($routeName, $ignoredPrefix)) { @@ -147,13 +184,4 @@ protected function isSiteAccessAwareRoute(string $routeName): bool return true; } - - /** - * Merges context from $simplifiedRequest into a clone of the current context. - */ - public function getContextBySimplifiedRequest(SimplifiedRequest $simplifiedRequest): RequestContext - { - // inline-instantiated on purpose as it's lightweight and injecting it here through DI can be complicated - return (new RequestContextFactory($this->context))->getContextBySimplifiedRequest($simplifiedRequest); - } } diff --git a/src/bundle/Debug/Collector/IbexaCoreCollector.php b/src/bundle/Debug/Collector/IbexaCoreCollector.php index e3cf10d475..9a5c277499 100644 --- a/src/bundle/Debug/Collector/IbexaCoreCollector.php +++ b/src/bundle/Debug/Collector/IbexaCoreCollector.php @@ -21,7 +21,7 @@ public function __construct() $this->reset(); } - public function collect(Request $request, Response $response, ?Throwable $exception = null) + public function collect(Request $request, Response $response, ?Throwable $exception = null): void { /** @var \Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface $innerCollector */ foreach ($this->data['collectors'] as $innerCollector) { @@ -104,7 +104,7 @@ public function getPanelTemplate($collectorName) /** * {@inheritdoc} */ - public function reset() + public function reset(): void { $this->data = [ 'collectors' => [], diff --git a/src/contracts/Repository/Values/ValueObject.php b/src/contracts/Repository/Values/ValueObject.php index 8ea2c030c8..125b586c1d 100644 --- a/src/contracts/Repository/Values/ValueObject.php +++ b/src/contracts/Repository/Values/ValueObject.php @@ -9,7 +9,7 @@ use Ibexa\Contracts\Core\Repository\Exceptions\PropertyNotFoundException; use Ibexa\Contracts\Core\Repository\Exceptions\PropertyReadOnlyException; -use Symfony\Component\Serializer\Annotation\Ignore as SerializerIgnore; +use Symfony\Component\Serializer\Attribute\Ignore as SerializerIgnore; /** * The base class for all value objects and structs. diff --git a/src/contracts/Validation/Constraint/UniqueIdentifier.php b/src/contracts/Validation/Constraint/UniqueIdentifier.php index 39d535bdaf..7cf88a0c7b 100644 --- a/src/contracts/Validation/Constraint/UniqueIdentifier.php +++ b/src/contracts/Validation/Constraint/UniqueIdentifier.php @@ -10,6 +10,7 @@ use JMS\TranslationBundle\Model\Message; use JMS\TranslationBundle\Translation\TranslationContainerInterface; +use Symfony\Component\Validator\Attribute\HasNamedArguments; use Symfony\Component\Validator\Constraint; abstract class UniqueIdentifier extends Constraint implements TranslationContainerInterface @@ -24,9 +25,27 @@ abstract class UniqueIdentifier extends Constraint implements TranslationContain public ?string $reportErrorPath = null; - public function getDefaultOption(): string - { - return 'identifierPath'; + /** + * @param string $identifierPath Property path of the identifier to check for uniqueness + * @param string|null $existingIdPath Property path of the ID of the object being updated, so it does not collide with itself + * @param string|null $reportErrorPath Property path to report the violation on (defaults to $identifierPath) + * @param array|null $groups + */ + #[HasNamedArguments] + public function __construct( + string $identifierPath, + ?string $existingIdPath = null, + ?string $reportErrorPath = null, + ?string $message = null, + ?array $groups = null, + mixed $payload = null + ) { + parent::__construct(null, $groups, $payload); + + $this->identifierPath = $identifierPath; + $this->existingIdPath = $existingIdPath; + $this->reportErrorPath = $reportErrorPath; + $this->message = $message ?? static::MESSAGE; } /** @@ -37,11 +56,6 @@ public function getTargets(): array return [self::CLASS_CONSTRAINT]; } - public function getRequiredOptions(): array - { - return ['identifierPath']; - } - public static function getTranslationMessages(): array { return [ diff --git a/src/lib/MVC/Symfony/Controller/Content/QueryController.php b/src/lib/MVC/Symfony/Controller/Content/QueryController.php index 35fdafd6bb..d800be5aa9 100644 --- a/src/lib/MVC/Symfony/Controller/Content/QueryController.php +++ b/src/lib/MVC/Symfony/Controller/Content/QueryController.php @@ -119,7 +119,7 @@ private function runPagingQuery(ContentView $view, Request $request) $limit = $queryParameters['limit'] ?? 10; $pageParam = $queryParameters['page_param'] ?? 'page'; - $page = $request->get($pageParam, 1); + $page = $request->query->getInt($pageParam, 1); $pager = new Pagerfanta( $this->getAdapter($this->contentViewQueryTypeMapper->map($view)) diff --git a/src/lib/MVC/Symfony/Controller/Controller.php b/src/lib/MVC/Symfony/Controller/Controller.php index 2e26e45a37..b2fda5a457 100644 --- a/src/lib/MVC/Symfony/Controller/Controller.php +++ b/src/lib/MVC/Symfony/Controller/Controller.php @@ -17,8 +17,8 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; -use Symfony\Component\Templating\EngineInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Twig\Environment; abstract class Controller implements ServiceSubscriberInterface { @@ -81,17 +81,19 @@ public function render($view, array $parameters = [], ?Response $response = null $response = new Response(); } - $response->setContent($this->getTemplateEngine()->render($view, $parameters)); + $response->setContent($this->getTwig()->render($view, $parameters)); return $response; } - /** - * @return \Symfony\Component\Templating\EngineInterface - */ - public function getTemplateEngine() + public function getTwig(): Environment { - return $this->container->get('templating'); + $twig = $this->container->get('twig'); + if (!$twig instanceof Environment) { + throw new \LogicException(sprintf('The "twig" service must be an instance of %s, %s given.', Environment::class, get_debug_type($twig))); + } + + return $twig; } /** @@ -142,7 +144,7 @@ public static function getSubscribedServices(): array { return [ 'logger' => '?' . LoggerInterface::class, - 'templating' => EngineInterface::class, + 'twig' => Environment::class, 'ibexa.config.resolver' => ConfigResolverInterface::class, 'ibexa.api.repository' => Repository::class, 'request_stack' => RequestStack::class, diff --git a/src/lib/MVC/Symfony/Controller/QueryRenderController.php b/src/lib/MVC/Symfony/Controller/QueryRenderController.php index 062190b6fa..e8e888ab98 100644 --- a/src/lib/MVC/Symfony/Controller/QueryRenderController.php +++ b/src/lib/MVC/Symfony/Controller/QueryRenderController.php @@ -48,7 +48,8 @@ public function renderQuery(Request $request, array $options): QueryView $results = new Pagerfanta($this->getAdapter($options)); if ($options['pagination']['enabled']) { - $currentPage = $request->get($options['pagination']['page_param'], 1); + $pageParam = $options['pagination']['page_param']; + $currentPage = $request->query->getInt($pageParam, 1); $results->setAllowOutOfRangePages(true); $results->setMaxPerPage($options['pagination']['limit']); @@ -71,7 +72,7 @@ private function resolveOptions(array $options): array { $resolver = new OptionsResolver(); - $resolver->setDefault('query', static function (OptionsResolver $resolver): void { + $resolver->setOptions('query', static function (OptionsResolver $resolver): void { $resolver->setDefaults([ 'parameters' => [], 'assign_results_to' => 'items', @@ -83,7 +84,7 @@ private function resolveOptions(array $options): array $resolver->setAllowedTypes('assign_results_to', 'string'); }); - $resolver->setDefault('pagination', static function (OptionsResolver $resolver): void { + $resolver->setOptions('pagination', static function (OptionsResolver $resolver): void { $resolver->setDefaults([ 'enabled' => true, 'limit' => 10, diff --git a/src/lib/MVC/Symfony/Security/Authorization/Voter/CoreVoter.php b/src/lib/MVC/Symfony/Security/Authorization/Voter/CoreVoter.php index 8a58d7ef21..afb9263ddb 100644 --- a/src/lib/MVC/Symfony/Security/Authorization/Voter/CoreVoter.php +++ b/src/lib/MVC/Symfony/Security/Authorization/Voter/CoreVoter.php @@ -10,6 +10,7 @@ use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Core\MVC\Symfony\Security\Authorization\Attribute as AuthorizationAttribute; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; class CoreVoter implements VoterInterface @@ -58,7 +59,7 @@ public function supportsClass($class): bool * * @return int either ACCESS_GRANTED, ACCESS_ABSTAIN, or ACCESS_DENIED */ - public function vote(TokenInterface $token, $object, array $attributes): int + public function vote(TokenInterface $token, $object, array $attributes, ?Vote $vote = null): int { foreach ($attributes as $attribute) { if ($this->supportsAttribute($attribute)) { diff --git a/src/lib/MVC/Symfony/Security/Authorization/Voter/ValueObjectVoter.php b/src/lib/MVC/Symfony/Security/Authorization/Voter/ValueObjectVoter.php index 422308faff..8f9bede9a5 100644 --- a/src/lib/MVC/Symfony/Security/Authorization/Voter/ValueObjectVoter.php +++ b/src/lib/MVC/Symfony/Security/Authorization/Voter/ValueObjectVoter.php @@ -10,6 +10,7 @@ use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Core\MVC\Symfony\Security\Authorization\Attribute as AuthorizationAttribute; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; /** @@ -54,7 +55,7 @@ public function supportsClass($class): bool * * @return int either ACCESS_GRANTED, ACCESS_ABSTAIN, or ACCESS_DENIED */ - public function vote(TokenInterface $token, $object, array $attributes): int + public function vote(TokenInterface $token, $object, array $attributes, ?Vote $vote = null): int { foreach ($attributes as $attribute) { if ($this->supportsAttribute($attribute)) { diff --git a/src/lib/MVC/Symfony/Security/User.php b/src/lib/MVC/Symfony/Security/User.php index db17bbc6d4..9a1e557d28 100644 --- a/src/lib/MVC/Symfony/Security/User.php +++ b/src/lib/MVC/Symfony/Security/User.php @@ -66,16 +66,6 @@ public function getPassword(): string return $this->getAPIUser()->getPasswordHash(); } - /** - * Returns the salt that was originally used to encode the password. - * - * This can return null if the password was not encoded using a salt. - */ - public function getSalt(): ?string - { - return null; - } - /** * Returns the username used to authenticate the user. */ @@ -90,11 +80,11 @@ public function getUserIdentifier(): string } /** - * Removes sensitive data from the user. + * Nothing to erase: the API user is never serialized (see {@see self::__sleep()}). * - * This is important if, at any given point, sensitive information like - * the plain-text password is stored on this object. + * @deprecated since Symfony 7.3, {@see \Symfony\Component\Security\Core\User\UserInterface::eraseCredentials()} is removed in Symfony 8.0 */ + #[\Deprecated] public function eraseCredentials(): void { } diff --git a/src/lib/MVC/Symfony/Security/UserChecker.php b/src/lib/MVC/Symfony/Security/UserChecker.php index 40703b4b8b..da7f48dcbc 100644 --- a/src/lib/MVC/Symfony/Security/UserChecker.php +++ b/src/lib/MVC/Symfony/Security/UserChecker.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Core\MVC\Symfony\Security\Exception\PasswordExpiredException; use Ibexa\Core\MVC\Symfony\Security\UserInterface as IbexaUserInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\DisabledException; use Symfony\Component\Security\Core\User\UserCheckerInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -39,7 +40,7 @@ public function checkPreAuth(UserInterface $user): void } } - public function checkPostAuth(UserInterface $user): void + public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void { if (!$user instanceof IbexaUserInterface) { return; diff --git a/src/lib/MVC/Symfony/Security/UserWrapped.php b/src/lib/MVC/Symfony/Security/UserWrapped.php index 941461a7e3..86a96c6971 100644 --- a/src/lib/MVC/Symfony/Security/UserWrapped.php +++ b/src/lib/MVC/Symfony/Security/UserWrapped.php @@ -93,9 +93,16 @@ public function getRoles(): array return $this->wrappedUser->getRoles(); } + /** + * @deprecated since Symfony 7.3, {@see \Symfony\Component\Security\Core\User\UserInterface::eraseCredentials()} is removed in Symfony 8.0 + */ + #[\Deprecated] public function eraseCredentials(): void { - $this->wrappedUser->eraseCredentials(); + $wrappedUserReflection = new \ReflectionObject($this->wrappedUser); + if ($wrappedUserReflection->hasMethod('eraseCredentials')) { + $wrappedUserReflection->getMethod('eraseCredentials')->invoke($this->wrappedUser); + } } public function isEqualTo(UserInterface $user): bool diff --git a/src/lib/MVC/Symfony/Templating/Twig/Extension/FieldRenderingExtension.php b/src/lib/MVC/Symfony/Templating/Twig/Extension/FieldRenderingExtension.php index bbf234881d..91eab082ed 100644 --- a/src/lib/MVC/Symfony/Templating/Twig/Extension/FieldRenderingExtension.php +++ b/src/lib/MVC/Symfony/Templating/Twig/Extension/FieldRenderingExtension.php @@ -50,7 +50,7 @@ public function __construct( $this->translationHelper = $translationHelper; } - public function getFunctions() + public function getFunctions(): array { $renderFieldCallable = function (Environment $environment, Content|ContentAwareInterface $data, $fieldIdentifier, array $params = []) { $this->fieldBlockRenderer->setTwig($environment); diff --git a/src/lib/QueryType/BuiltIn/AbstractQueryType.php b/src/lib/QueryType/BuiltIn/AbstractQueryType.php index 28cf18b62d..2bed8e63fb 100644 --- a/src/lib/QueryType/BuiltIn/AbstractQueryType.php +++ b/src/lib/QueryType/BuiltIn/AbstractQueryType.php @@ -46,18 +46,19 @@ public function __construct( protected function configureOptions(OptionsResolver $resolver): void { + $resolver->setOptions('filter', static function (OptionsResolver $resolver): void { + $resolver->setDefaults([ + 'content_type' => [], + 'visible_only' => true, + 'siteaccess_aware' => true, + ]); + + $resolver->setAllowedTypes('content_type', 'array'); + $resolver->setAllowedTypes('visible_only', 'bool'); + $resolver->setAllowedTypes('siteaccess_aware', 'bool'); + }); + $resolver->setDefaults([ - 'filter' => static function (OptionsResolver $resolver): void { - $resolver->setDefaults([ - 'content_type' => [], - 'visible_only' => true, - 'siteaccess_aware' => true, - ]); - - $resolver->setAllowedTypes('content_type', 'array'); - $resolver->setAllowedTypes('visible_only', 'bool'); - $resolver->setAllowedTypes('siteaccess_aware', 'bool'); - }, 'offset' => 0, 'limit' => self::DEFAULT_LIMIT, 'sort' => [], diff --git a/src/lib/Repository/Validator/Constraint/LocationIsContainerContentType.php b/src/lib/Repository/Validator/Constraint/LocationIsContainerContentType.php index 5914d107b0..aeda683dad 100644 --- a/src/lib/Repository/Validator/Constraint/LocationIsContainerContentType.php +++ b/src/lib/Repository/Validator/Constraint/LocationIsContainerContentType.php @@ -8,6 +8,7 @@ namespace Ibexa\Core\Repository\Validator\Constraint; +use Symfony\Component\Validator\Attribute\HasNamedArguments; use Symfony\Component\Validator\Constraint; final class LocationIsContainerContentType extends Constraint @@ -21,16 +22,15 @@ final class LocationIsContainerContentType extends Constraint public string $message = 'Location with {{ contentTypeName }} is not a container content type.'; /** - * @param array|null $options * @param array|null $groups */ + #[HasNamedArguments] public function __construct( - ?array $options = null, ?string $message = null, ?array $groups = null, mixed $payload = null ) { - parent::__construct($options ?? [], $groups, $payload); + parent::__construct(null, $groups, $payload); $this->message = $message ?? $this->message; } diff --git a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php index a97122ffd3..c557e62eb7 100644 --- a/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php +++ b/tests/bundle/Core/DependencyInjection/Compiler/ChainRoutingPassTest.php @@ -9,8 +9,6 @@ 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 Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -74,9 +72,6 @@ public function testAddRouterWithDefaultRouter($declaredPriority, $expectedPrior { $defaultRouter = new Definition(); $this->setDefinition('router.default', $defaultRouter); - $this->setDefinition(SiteAccess::class, new Definition()); - $this->setDefinition('ibexa.config.resolver', new Definition()); - $this->setDefinition(Router::class, new Definition()); $resolverDef = new Definition(); $serviceId = 'some_service_id'; @@ -90,26 +85,7 @@ public function testAddRouterWithDefaultRouter($declaredPriority, $expectedPrior $this->compile(); // Assertion for default router - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( - 'router.default', - 'setSiteAccess', - [new Reference(SiteAccess::class)] - ); - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( - 'router.default', - 'setConfigResolver', - [new Reference('ibexa.config.resolver')] - ); - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( - 'router.default', - 'setNonSiteAccessAwareRoutes', - ['%ibexa.default_router.non_site_access_aware_routes%'] - ); - $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( - 'router.default', - 'setSiteAccessRouter', - [new Reference(Router::class)] - ); + $this->assertContainerBuilderHasServiceDefinitionWithTag('router.default', 'router', ['priority' => 255]); $this->assertContainerBuilderHasServiceDefinitionWithMethodCall( ChainRouter::class, 'add', diff --git a/tests/bundle/Core/EventSubscriber/TrustedHeaderClientIpEventSubscriberTest.php b/tests/bundle/Core/EventSubscriber/TrustedHeaderClientIpEventSubscriberTest.php index 29de81a1b6..d30e172a87 100644 --- a/tests/bundle/Core/EventSubscriber/TrustedHeaderClientIpEventSubscriberTest.php +++ b/tests/bundle/Core/EventSubscriber/TrustedHeaderClientIpEventSubscriberTest.php @@ -19,24 +19,15 @@ final class TrustedHeaderClientIpEventSubscriberTest extends TestCase { - private ?string $originalRemoteAddr; + private ?string $originalRemoteAddr = null; private const string PROXY_IP = '127.100.100.1'; private const string REAL_CLIENT_IP = '98.76.123.234'; - /** - * @param array $data - */ - public function __construct(?string $name = null, array $data = [], string $dataName = '') - { - parent::__construct($name, $data, $dataName); - - $this->originalRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; - } - protected function setUp(): void { + $this->originalRemoteAddr = $_SERVER['REMOTE_ADDR'] ?? null; $_SERVER['REMOTE_ADDR'] = null; Request::setTrustedProxies([], -1); } diff --git a/tests/bundle/Core/Routing/DefaultRouterTest.php b/tests/bundle/Core/Routing/DefaultRouterTest.php index cd3a45a3a3..d39a98301a 100644 --- a/tests/bundle/Core/Routing/DefaultRouterTest.php +++ b/tests/bundle/Core/Routing/DefaultRouterTest.php @@ -4,141 +4,103 @@ * @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\Bundle\Core\Routing; use Ibexa\Bundle\Core\Routing\DefaultRouter; use Ibexa\Bundle\Core\SiteAccess\Matcher; -use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\MVC\Symfony\Routing\SimplifiedRequest; use Ibexa\Core\MVC\Symfony\SiteAccess; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use ReflectionObject; -use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Bundle\FrameworkBundle\Routing\Router as FrameworkRouter; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Exception\InvalidParameterException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Routing\Matcher\UrlMatcherInterface; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Router; -class DefaultRouterTest extends TestCase +/** + * @covers \Ibexa\Bundle\Core\Routing\DefaultRouter + */ +final class DefaultRouterTest extends TestCase { - /** @var \PHPUnit\Framework\MockObject\MockObject|\Symfony\Component\DependencyInjection\ContainerInterface */ - protected $container; + private const array NON_SITEACCESS_AWARE_ROUTES = ['_dontwantsiteaccess']; + + private Router&MockObject $innerRouter; - /** @var \PHPUnit\Framework\MockObject\MockObject|\Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface */ - protected $configResolver; + private SiteAccess\SiteAccessRouterInterface&MockObject $siteAccessRouter; - /** @var \Symfony\Component\Routing\RequestContext */ - protected $requestContext; + private RequestContext $requestContext; protected function setUp(): void { parent::setUp(); - $this->container = $this->createMock(ContainerInterface::class); - $this->configResolver = $this->createMock(ConfigResolverInterface::class); + $this->innerRouter = $this->createMock(Router::class); + $this->siteAccessRouter = $this->createMock(SiteAccess\SiteAccessRouterInterface::class); $this->requestContext = new RequestContext(); + $this->innerRouter->method('getContext')->willReturnCallback(fn (): RequestContext => $this->requestContext); } - /** - * @return class-string<\Ibexa\Bundle\Core\Routing\DefaultRouter> - */ - protected function getRouterClass(): string - { - return DefaultRouter::class; - } - - /** - * @param array $mockedMethods - * - * @return \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter - */ - protected function generateRouter(array $mockedMethods = []) + private function createRouter(): DefaultRouter { - /** @var \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter $router */ - $router = $this - ->getMockBuilder($this->getRouterClass()) - ->setConstructorArgs([$this->container, 'foo', [], $this->requestContext]) - ->setMethods(array_merge($mockedMethods)) - ->getMock(); - $router->setConfigResolver($this->configResolver); - - return $router; + return new DefaultRouter($this->innerRouter, $this->siteAccessRouter, self::NON_SITEACCESS_AWARE_ROUTES); } - public function testMatchRequestWithSemanticPathinfo() + public function testMatchRequestWithSemanticPathinfo(): void { $pathinfo = '/siteaccess/foo/bar'; $semanticPathinfo = '/foo/bar'; $request = Request::create($pathinfo); $request->attributes->set('semanticPathinfo', $semanticPathinfo); - - /** @var \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter $router */ - $router = $this->generateRouter(['getMatcher']); $matchedParameters = ['_controller' => 'AcmeBundle:myAction']; - $matcher = $this->createMock(UrlMatcherInterface::class); - $matcher->expects(self::once()) - ->method('match') - ->with($semanticPathinfo) - ->willReturn($matchedParameters); - - $router + $this->innerRouter ->expects(self::once()) - ->method('getMatcher') - ->willReturn($matcher); + ->method('matchRequest') + ->with(self::callback( + static fn (Request $matchedRequest): bool => $matchedRequest->getPathInfo() === $semanticPathinfo + )) + ->willReturn($matchedParameters); - self::assertSame($matchedParameters, $router->matchRequest($request)); + self::assertSame($matchedParameters, $this->createRouter()->matchRequest($request)); + // The original request must not be altered + self::assertSame($pathinfo, $request->getPathInfo()); } - public function testMatchRequestRegularPathinfo() + public function testMatchRequestRegularPathinfo(): void { $matchedParameters = ['_controller' => 'AcmeBundle:myAction']; - $pathinfo = '/siteaccess/foo/bar'; + $request = Request::create('/siteaccess/foo/bar'); - $request = Request::create($pathinfo); - - $this->configResolver->expects(self::never())->method('getParameter'); - - /** @var \PHPUnit\Framework\MockObject\MockObject&\Ibexa\Bundle\Core\Routing\DefaultRouter $router */ - $router = $this->generateRouter(['getMatcher']); - - $matcher = $this->createMock(UrlMatcherInterface::class); - $matcher->expects(self::once()) - ->method('match') - ->with($pathinfo) - ->willReturn($matchedParameters); - - $router + $this->innerRouter ->expects(self::once()) - ->method('getMatcher') - ->willReturn($matcher); + ->method('matchRequest') + ->with(self::identicalTo($request)) + ->willReturn($matchedParameters); - self::assertSame($matchedParameters, $router->matchRequest($request)); + self::assertSame($matchedParameters, $this->createRouter()->matchRequest($request)); } /** * @dataProvider providerGenerateNoSiteAccess */ - public function testGenerateNoSiteAccess($url) + public function testGenerateNoSiteAccess(string $url): void { - $generator = $this->createMock(UrlGeneratorInterface::class); - $generator + $this->innerRouter ->expects(self::once()) ->method('generate') ->with(__METHOD__) ->willReturn($url); - /** @var \Ibexa\Bundle\Core\Routing\DefaultRouter&\PHPUnit\Framework\MockObject\MockObject $router */ - $router = $this->generateRouter(['getGenerator']); - $router - ->expects(self::any()) - ->method('getGenerator') - ->willReturn($generator); - - self::assertSame($url, $router->generate(__METHOD__)); + self::assertSame($url, $this->createRouter()->generate(__METHOD__)); } - public function providerGenerateNoSiteAccess() + /** + * @return iterable + */ + public function providerGenerateNoSiteAccess(): iterable { return [ ['/foo/bar'], @@ -157,31 +119,28 @@ public function providerGenerateNoSiteAccess() * @param string $saName The SiteAccess name * @param bool $isMatcherLexer True if the siteaccess matcher is URILexer * @param int $referenceType The type of reference to be generated (one of the constants) - * @param string $routeName */ - public function testGenerateWithSiteAccess($urlGenerated, $relevantUri, $expectedUrl, $saName, $isMatcherLexer, $referenceType, $routeName) - { + public function testGenerateWithSiteAccess( + string $urlGenerated, + string $relevantUri, + string $expectedUrl, + string $saName, + bool $isMatcherLexer, + int $referenceType, + ?string $routeName + ): void { $routeName = $routeName ?: __METHOD__; - $nonSiteAccessAwareRoutes = ['_dontwantsiteaccess']; - $generator = $this->createMock(UrlGeneratorInterface::class); - $generator + $this->innerRouter ->expects(self::once()) ->method('generate') ->with($routeName) ->willReturn($urlGenerated); - /** @var \Ibexa\Bundle\Core\Routing\DefaultRouter&\PHPUnit\Framework\MockObject\MockObject $router */ - $router = $this->generateRouter(['getGenerator']); - $router - ->expects(self::any()) - ->method('getGenerator') - ->willReturn($generator); - // If matcher is URILexer, we make it act as it's supposed to, prepending the siteaccess. if ($isMatcherLexer) { $matcher = $this->createMock(SiteAccess\URILexer::class); // Route is siteaccess aware, we're expecting analyseLink() to be called - if (!in_array($routeName, $nonSiteAccessAwareRoutes)) { + if (!in_array($routeName, self::NON_SITEACCESS_AWARE_ROUTES, true)) { $matcher ->expects(self::once()) ->method('analyseLink') @@ -197,30 +156,30 @@ public function testGenerateWithSiteAccess($urlGenerated, $relevantUri, $expecte $matcher = $this->createMock(Matcher::class); } - $sa = new SiteAccess($saName, 'test', $matcher); - $router->setSiteAccess($sa); - - $requestContext = new RequestContext(); $urlComponents = parse_url($urlGenerated); if (isset($urlComponents['host'])) { - $requestContext->setHost($urlComponents['host']); - $requestContext->setScheme($urlComponents['scheme']); + $this->requestContext->setHost($urlComponents['host']); + $this->requestContext->setScheme($urlComponents['scheme']); if (isset($urlComponents['port']) && $urlComponents['scheme'] === 'http') { - $requestContext->setHttpPort($urlComponents['port']); + $this->requestContext->setHttpPort($urlComponents['port']); } elseif (isset($urlComponents['port']) && $urlComponents['scheme'] === 'https') { - $requestContext->setHttpsPort($urlComponents['port']); + $this->requestContext->setHttpsPort($urlComponents['port']); } } - $requestContext->setBaseUrl( + $this->requestContext->setBaseUrl( substr($urlComponents['path'], 0, strpos($urlComponents['path'], $relevantUri)) ); - $router->setContext($requestContext); - $router->setNonSiteAccessAwareRoutes($nonSiteAccessAwareRoutes); + + $router = $this->createRouter(); + $router->setSiteAccess(new SiteAccess($saName, 'test', $matcher)); self::assertSame($expectedUrl, $router->generate($routeName, [], $referenceType)); } - public function providerGenerateWithSiteAccess() + /** + * @return iterable + */ + public function providerGenerateWithSiteAccess(): iterable { return [ ['/foo/bar', '/foo/bar', '/foo/bar', 'test_siteaccess', false, UrlGeneratorInterface::ABSOLUTE_PATH, null], @@ -238,108 +197,105 @@ public function providerGenerateWithSiteAccess() ]; } - public function testGenerateReverseSiteAccessMatch() + public function testGenerateReverseSiteAccessMatch(): void { $routeName = 'some_route_name'; $urlGenerated = 'http://phoenix-rises.fm/foo/bar'; $siteAccessName = 'foo_test'; - $siteAccessRouter = $this->createMock(SiteAccess\SiteAccessRouterInterface::class); - $versatileMatcher = $this->createMock(SiteAccess\VersatileMatcher::class); - $simplifiedRequest = new SimplifiedRequest('http', 'phoenix-rises.fm'); - $versatileMatcher - ->expects(self::once()) - ->method('getRequest') - ->willReturn($simplifiedRequest); - $siteAccessRouter + $contexts = []; + $this->expectReverseSiteAccessMatch( + $siteAccessName, + new SimplifiedRequest('http', 'phoenix-rises.fm'), + $contexts + ); + $this->innerRouter ->expects(self::once()) - ->method('matchByName') - ->with($siteAccessName) - ->willReturn(new SiteAccess($siteAccessName, 'foo', $versatileMatcher)); - - $generator = $this->createMock(UrlGeneratorInterface::class); - $generator - ->expects(self::at(0)) - ->method('setContext') - ->with(self::isInstanceOf(RequestContext::class)); - $generator - ->expects(self::at(1)) ->method('generate') - ->with($routeName) + ->with($routeName, []) ->willReturn($urlGenerated); - $generator - ->expects(self::at(2)) - ->method('setContext') - ->with($this->requestContext); - $router = new DefaultRouter($this->container, 'foo', [], $this->requestContext); - $router->setConfigResolver($this->configResolver); + $router = $this->createRouter(); $router->setSiteAccess(new SiteAccess('test', 'test', $this->createMock(Matcher::class))); - $router->setSiteAccessRouter($siteAccessRouter); - $refRouter = new ReflectionObject($router); - $refGenerator = $refRouter->getProperty('generator'); - $refGenerator->setAccessible(true); - $refGenerator->setValue($router, $generator); self::assertSame( $urlGenerated, $router->generate($routeName, ['siteaccess' => $siteAccessName], DefaultRouter::ABSOLUTE_PATH) ); + + // Context is switched to the target SiteAccess for generation, then restored + self::assertCount(2, $contexts); + self::assertSame('phoenix-rises.fm', $contexts[0]->getHost()); + self::assertNotSame($this->requestContext, $contexts[0]); + self::assertSame($this->requestContext, $contexts[1]); } - /** - * @dataProvider providerGetContextBySimplifiedRequest - * - * @param string $uri - */ - public function testGetContextBySimplifiedRequest($uri) + public function testGenerateRestoresContextWhenInnerRouterThrows(): void { - $this->getExpectedRequestContext($uri); - - $router = new DefaultRouter($this->container, 'foo', [], $this->requestContext); - - self::assertEquals( - $this->getExpectedRequestContext($uri), - $router->getContextBySimplifiedRequest(SimplifiedRequest::fromUrl($uri)) + $siteAccessName = 'foo_test'; + $contexts = []; + $this->expectReverseSiteAccessMatch( + $siteAccessName, + new SimplifiedRequest('https', 'example.com'), + $contexts ); + $this->innerRouter + ->expects(self::once()) + ->method('generate') + ->willThrowException(new InvalidParameterException()); + + $this->expectException(InvalidParameterException::class); + try { + $this->createRouter()->generate('route', ['siteaccess' => $siteAccessName]); + } finally { + self::assertCount(2, $contexts); + self::assertNotSame($this->requestContext, $contexts[0]); + self::assertSame($this->requestContext, $contexts[1]); + } } - /** - * Data provider for testGetContextBySimplifiedRequest. - * - * @see testGetContextBySimplifiedRequest - * - * @phpstan-return array - */ - public function providerGetContextBySimplifiedRequest() + public function testWarmUpDelegatesToInnerRouter(): void { - return [ - ['/foo/bar'], - ['http://ezpublish.dev/foo/bar'], - ['http://ezpublish.dev:8080/foo/bar'], - ['https://ezpublish.dev/secured'], - ['https://ezpublish.dev:445/secured'], - ['http://ezpublish.dev:8080/foo/root_folder/bar/baz'], - ]; + $innerRouter = $this->createMock(FrameworkRouter::class); + $innerRouter + ->expects(self::once()) + ->method('warmUp') + ->with('/cache', '/build') + ->willReturn(['/cache/routes.php']); + + $router = new DefaultRouter($innerRouter, $this->siteAccessRouter); + + self::assertSame(['/cache/routes.php'], $router->warmUp('/cache', '/build')); } - private function getExpectedRequestContext($uri) + public function testWarmUpDoesNothingWhenInnerRouterIsNotWarmable(): void { - $requestContext = new RequestContext(); - $uriComponents = parse_url($uri); - if (isset($uriComponents['host'])) { - $requestContext->setHost($uriComponents['host']); - $requestContext->setScheme($uriComponents['scheme']); - if (isset($uriComponents['port']) && $uriComponents['scheme'] === 'http') { - $requestContext->setHttpPort($uriComponents['port']); - } elseif (isset($uriComponents['port']) && $uriComponents['scheme'] === 'https') { - $requestContext->setHttpsPort($uriComponents['port']); - } - } - if (isset($uriComponents['path'])) { - $requestContext->setPathInfo($uriComponents['path']); - } + self::assertSame([], $this->createRouter()->warmUp('/cache', '/build')); + } - return $requestContext; + /** + * @param \Symfony\Component\Routing\RequestContext[] $contexts + */ + private function expectReverseSiteAccessMatch( + string $siteAccessName, + SimplifiedRequest $simplifiedRequest, + array &$contexts + ): void { + $versatileMatcher = $this->createMock(SiteAccess\VersatileMatcher::class); + $versatileMatcher + ->expects(self::once()) + ->method('getRequest') + ->willReturn($simplifiedRequest); + $this->siteAccessRouter + ->expects(self::once()) + ->method('matchByName') + ->with($siteAccessName) + ->willReturn(new SiteAccess($siteAccessName, 'foo', $versatileMatcher)); + $this->innerRouter + ->expects(self::exactly(2)) + ->method('setContext') + ->willReturnCallback(static function (RequestContext $context) use (&$contexts): void { + $contexts[] = $context; + }); } } diff --git a/tests/integration/Core/MVC/Symfony/Controller/Content/DownloadControllerRequestFlowTest.php b/tests/integration/Core/MVC/Symfony/Controller/Content/DownloadControllerRequestFlowTest.php index 1030dfd57b..f7acd3ee21 100644 --- a/tests/integration/Core/MVC/Symfony/Controller/Content/DownloadControllerRequestFlowTest.php +++ b/tests/integration/Core/MVC/Symfony/Controller/Content/DownloadControllerRequestFlowTest.php @@ -8,11 +8,14 @@ namespace Ibexa\Tests\Integration\Core\MVC\Symfony\Controller\Content; +use Ibexa\Bundle\Core\Routing\DefaultRouter; use Ibexa\Bundle\IO\BinaryStreamResponse; use Ibexa\Contracts\Core\Repository\Values\Content\Field; use Ibexa\Contracts\Core\Test\IbexaKernelTestCase; +use Ibexa\Core\MVC\Symfony\Routing\ChainRouter; use Ibexa\Tests\Core\MVC\Symfony\Controller\Controller\Content\DownloadControllerTestTrait; use Ibexa\Tests\Integration\Core\MVC\Symfony\InternalRoutingTestKernel; +use Symfony\Bundle\FrameworkBundle\Routing\Router as FrameworkRouter; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -91,6 +94,23 @@ public function testDownloadsFileWithUrlEncodedFilename(): void self::assertInstanceOf(BinaryStreamResponse::class, $response); } + public function testDefaultRouterDecoratesFrameworkRouterAndOccursOnceInChain(): void + { + $defaultRouter = self::getContainer()->get('router.default'); + self::assertInstanceOf(DefaultRouter::class, $defaultRouter); + self::assertInstanceOf(FrameworkRouter::class, self::getContainer()->get('ibexa.routing.default_router.inner')); + + $chainRouter = self::getContainer()->get('test.ibexa.chain_router'); + self::assertInstanceOf(ChainRouter::class, $chainRouter); + self::assertCount( + 1, + array_filter( + $chainRouter->all(), + static fn (object $router): bool => $router === $defaultRouter + ) + ); + } + private function configureDownloadController(RouteCollection $routes): void { $route = $routes->get('ibexa.content.download'); diff --git a/tests/integration/Core/MVC/Symfony/InternalRoutingTestKernel.php b/tests/integration/Core/MVC/Symfony/InternalRoutingTestKernel.php index bf4a4c28fb..9f233f0810 100644 --- a/tests/integration/Core/MVC/Symfony/InternalRoutingTestKernel.php +++ b/tests/integration/Core/MVC/Symfony/InternalRoutingTestKernel.php @@ -9,6 +9,7 @@ namespace Ibexa\Tests\Integration\Core\MVC\Symfony; use Ibexa\Contracts\Core\Test\IbexaTestKernel; +use Ibexa\Core\MVC\Symfony\Routing\ChainRouter; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -20,6 +21,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void $loader->load(static function (ContainerBuilder $container): void { self::loadRouting($container); + $container->setAlias('test.ibexa.chain_router', ChainRouter::class)->setPublic(true); }); } diff --git a/tests/lib/MVC/Symfony/Controller/ControllerTest.php b/tests/lib/MVC/Symfony/Controller/ControllerTest.php index 3bf3bc6b31..35b001520c 100644 --- a/tests/lib/MVC/Symfony/Controller/ControllerTest.php +++ b/tests/lib/MVC/Symfony/Controller/ControllerTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Templating\EngineInterface; +use Twig\Environment; /** * @covers \Ibexa\Core\MVC\Symfony\Controller\Controller::render @@ -31,13 +31,13 @@ class ControllerTest extends TestCase protected function setUp(): void { - $this->templateEngineMock = $this->createMock(EngineInterface::class); + $this->templateEngineMock = $this->createMock(Environment::class); $this->containerMock = $this->createMock(ContainerInterface::class); $this->controller = $this->getMockForAbstractClass(Controller::class, [$this->containerMock]); $this->containerMock ->expects(self::any()) ->method('get') - ->with('templating') + ->with('twig') ->will(self::returnValue($this->templateEngineMock)); } diff --git a/tests/lib/MVC/Symfony/Controller/QueryRenderControllerTest.php b/tests/lib/MVC/Symfony/Controller/QueryRenderControllerTest.php index eb3ee41bbf..1343336d1c 100644 --- a/tests/lib/MVC/Symfony/Controller/QueryRenderControllerTest.php +++ b/tests/lib/MVC/Symfony/Controller/QueryRenderControllerTest.php @@ -111,6 +111,25 @@ public function testRenderQueryWithAllOptions(): void ); } + public function testPaginationReadsPageFromQueryStringOnly(): void + { + $adapter = $this->configureMocks(self::ALL_OPTIONS); + + $items = new Pagerfanta($adapter); + $items->setAllowOutOfRangePages(true); + $items->setCurrentPage(2); + $items->setMaxPerPage(self::EXAMPLE_MAX_PER_PAGE); + + // request body and attributes carrying the same parameter must not influence pagination + $this->assertRenderQueryResult( + new QueryView('example.html.twig', [ + 'results' => $items, + ]), + self::ALL_OPTIONS, + new Request(['p' => 2], ['p' => 3], ['p' => 4]) + ); + } + /** * @phpstan-param TOptionsArray $options * diff --git a/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php b/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php index c305df6cbb..8802033e18 100644 --- a/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php +++ b/tests/lib/MVC/Symfony/Security/Authentication/EventSubscriber/RepositoryUserAuthenticationSubscriberTest.php @@ -164,7 +164,8 @@ private function getCheckPassportEvent( $passport = new Passport( new UserBadge( - $user->getUserIdentifier(), + // mocked users return an empty identifier, which Symfony 8 rejects + $user->getUserIdentifier() ?: 'user', static fn (string $userIdentifier): IbexaUserInterface => $userProvider->loadUserByIdentifier($userIdentifier) ), new PasswordCredentials($user->getPassword()) diff --git a/tests/lib/MVC/Symfony/Security/UserTest.php b/tests/lib/MVC/Symfony/Security/UserTest.php index 5561078ba1..94a7efb783 100644 --- a/tests/lib/MVC/Symfony/Security/UserTest.php +++ b/tests/lib/MVC/Symfony/Security/UserTest.php @@ -45,7 +45,6 @@ public function testConstruct() self::assertSame($login, $user->getUsername()); self::assertSame($passwordHash, $user->getPassword()); self::assertSame($roles, $user->getRoles()); - self::assertNull($user->getSalt()); } public function testIsEqualTo() diff --git a/tests/lib/Search/Common/FieldValueMapper/RemoteIdentifierMapperTest.php b/tests/lib/Search/Common/FieldValueMapper/RemoteIdentifierMapperTest.php index b005a84866..b90fe383e5 100644 --- a/tests/lib/Search/Common/FieldValueMapper/RemoteIdentifierMapperTest.php +++ b/tests/lib/Search/Common/FieldValueMapper/RemoteIdentifierMapperTest.php @@ -126,7 +126,7 @@ public function getDataForTestMap(): iterable yield 'identifier with non-printable characters' => [ new Field( 'identifier', - utf8_decode("Non\x09Printable\x0EIdentifier"), + mb_convert_encoding("Non\x09Printable\x0EIdentifier", 'ISO-8859-1', 'UTF-8'), new IdentifierField() ), 'Non PrintableIdentifier', diff --git a/tests/lib/Validation/Constraint/UniqueIdentifierTest.php b/tests/lib/Validation/Constraint/UniqueIdentifierTest.php new file mode 100644 index 0000000000..5346c60bfe --- /dev/null +++ b/tests/lib/Validation/Constraint/UniqueIdentifierTest.php @@ -0,0 +1,64 @@ +identifierPath); + self::assertSame('id', $constraint->existingIdPath); + self::assertSame('identifier', $constraint->reportErrorPath); + self::assertSame('Already exists', $constraint->message); + self::assertSame(['custom'], $constraint->groups); + self::assertSame($payload, $constraint->payload); + } + + public function testIdentifierPathIsTheOnlyRequiredArgument(): void + { + $constraint = new class('identifier') extends UniqueIdentifier { + }; + + self::assertSame('identifier', $constraint->identifierPath); + self::assertNull($constraint->existingIdPath); + self::assertNull($constraint->reportErrorPath); + self::assertSame('ibexa.identifier_already_exists', $constraint->message); + self::assertSame([UniqueIdentifier::DEFAULT_GROUP], $constraint->groups); + self::assertNull($constraint->payload); + self::assertSame([UniqueIdentifier::CLASS_CONSTRAINT], $constraint->getTargets()); + } + + public function testConstructorSupportsNamedArgumentsForMappingLoaders(): void + { + $constructor = new \ReflectionMethod(UniqueIdentifier::class, '__construct'); + + self::assertNotEmpty( + $constructor->getAttributes(HasNamedArguments::class), + 'Symfony mapping loaders (YAML/XML/attributes) rely on #[HasNamedArguments] to pass options as named arguments.' + ); + } +}