From 10ea35d5d6b8e041a96b2a351929d11b7497ebfd Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Wed, 16 Nov 2022 09:33:10 +0000 Subject: [PATCH] Issue #3227824 by brianperry, nod_, D34dMan, ravi.shankar, alexpott, johnny5th, bbrala, yogeshmpawar, Suresh Prabhu Parkala, larowlan, joachim, benjifisher, baddysonja, xjm, gabesullice, casey, lauriii: Move the linkset functionality from the decoupled menus contributed module to core's system module --- core/misc/cspell/dictionary.txt | 1 + .../Functional/JsonApiRequestTestTrait.php | 63 +--- .../config/install/system.feature_flags.yml | 1 + .../system/config/schema/system.schema.yml | 8 + .../src/Controller/LinksetController.php | 281 +++++++++++++++ .../src/Form/MenuLinksetSettingsForm.php | 72 ++++ .../system/src/Routing/MenuLinksetRoutes.php | 119 +++++++ core/modules/system/system.links.menu.yml | 7 + core/modules/system/system.post_update.php | 8 + core/modules/system/system.routing.yml | 9 + core/modules/system/system.services.yml | 5 + .../linkset-menu-main-multilingual-aa.json | 33 ++ .../linkset-menu-main-multilingual-bb.json | 33 ++ .../linkset-menu-main-multilingual-cc.json | 33 ++ .../linkset-menu-main-multilingual-dd.json | 33 ++ ...inkset-menu-main-multilingual-default.json | 33 ++ .../fixtures/linkset/linkset-menu-main.json | 47 +++ .../decoupled_menus_test.info.yml | 8 + .../decoupled_menus_test.module | 17 + .../Form/MenuLinksetSettingsFormTest.php | 75 ++++ .../LinksetControllerMultiLingualTest.php | 223 ++++++++++++ .../Functional/Menu/LinksetControllerTest.php | 334 ++++++++++++++++++ .../Menu/LinksetControllerTestBase.php | 182 ++++++++++ .../Update/MenuLinksetSettingsUpdateTest.php | 41 +++ core/tests/Drupal/Tests/ApiRequestTrait.php | 74 ++++ 25 files changed, 1680 insertions(+), 60 deletions(-) create mode 100644 core/modules/system/config/install/system.feature_flags.yml create mode 100644 core/modules/system/src/Controller/LinksetController.php create mode 100644 core/modules/system/src/Form/MenuLinksetSettingsForm.php create mode 100644 core/modules/system/src/Routing/MenuLinksetRoutes.php create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-aa.json create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-bb.json create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-cc.json create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-dd.json create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-default.json create mode 100644 core/modules/system/tests/fixtures/linkset/linkset-menu-main.json create mode 100644 core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.info.yml create mode 100644 core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.module create mode 100644 core/modules/system/tests/src/Functional/Form/MenuLinksetSettingsFormTest.php create mode 100644 core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php create mode 100644 core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php create mode 100644 core/modules/system/tests/src/Functional/Menu/LinksetControllerTestBase.php create mode 100644 core/modules/system/tests/src/Functional/Update/MenuLinksetSettingsUpdateTest.php create mode 100644 core/tests/Drupal/Tests/ApiRequestTrait.php diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 85d7cb2eed1e..60431ac7b1ea 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -643,6 +643,7 @@ linkback linkgenerator linkification linksby +linkset linktext lisu litererally diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php index 7d9208755003..571513e8100f 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRequestTestTrait.php @@ -2,9 +2,7 @@ namespace Drupal\Tests\jsonapi\Functional; -use Behat\Mink\Driver\BrowserKitDriver; -use Drupal\Core\Url; -use GuzzleHttp\RequestOptions; +use Drupal\Tests\ApiRequestTrait; /** * Boilerplate for JSON:API Functional tests' HTTP requests. @@ -12,63 +10,8 @@ * @internal */ trait JsonApiRequestTestTrait { - - /** - * Performs a HTTP request. Wraps the Guzzle HTTP client. - * - * Why wrap the Guzzle HTTP client? Because we want to keep the actual test - * code as simple as possible, and hence not require them to specify the - * 'http_errors = FALSE' request option, nor do we want them to have to - * convert Drupal Url objects to strings. - * - * We also don't want to follow redirects automatically, to ensure these tests - * are able to detect when redirects are added or removed. - * - * @param string $method - * HTTP method. - * @param \Drupal\Core\Url $url - * URL to request. - * @param array $request_options - * Request options to apply. - * - * @return \Psr\Http\Message\ResponseInterface - * The response. - * - * @see \GuzzleHttp\ClientInterface::request() - */ - protected function request($method, Url $url, array $request_options) { - $this->refreshVariables(); - $request_options[RequestOptions::HTTP_ERRORS] = FALSE; - $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE; - $request_options = $this->decorateWithXdebugCookie($request_options); - $client = $this->getSession()->getDriver()->getClient()->getClient(); - return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options); - } - - /** - * Adds the Xdebug cookie to the request options. - * - * @param array $request_options - * The request options. - * - * @return array - * Request options updated with the Xdebug cookie if present. - */ - protected function decorateWithXdebugCookie(array $request_options) { - $session = $this->getSession(); - $driver = $session->getDriver(); - if ($driver instanceof BrowserKitDriver) { - $client = $driver->getClient(); - foreach ($client->getCookieJar()->all() as $cookie) { - if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) { - $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue(); - } - else { - $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue(); - } - } - } - return $request_options; + use ApiRequestTrait { + makeApiRequest as request; } } diff --git a/core/modules/system/config/install/system.feature_flags.yml b/core/modules/system/config/install/system.feature_flags.yml new file mode 100644 index 000000000000..7169df08b775 --- /dev/null +++ b/core/modules/system/config/install/system.feature_flags.yml @@ -0,0 +1 @@ +linkset_endpoint: false diff --git a/core/modules/system/config/schema/system.schema.yml b/core/modules/system/config/schema/system.schema.yml index c0f6a87e46fe..48db22a23cfa 100644 --- a/core/modules/system/config/schema/system.schema.yml +++ b/core/modules/system/config/schema/system.schema.yml @@ -350,3 +350,11 @@ condition.plugin.request_path: mapping: pages: type: string + +system.feature_flags: + type: config_object + label: 'System Feature Flags' + mapping: + linkset_endpoint: + type: boolean + label: 'Enable the menu linkset endpoint' diff --git a/core/modules/system/src/Controller/LinksetController.php b/core/modules/system/src/Controller/LinksetController.php new file mode 100644 index 000000000000..35588c789a2e --- /dev/null +++ b/core/modules/system/src/Controller/LinksetController.php @@ -0,0 +1,281 @@ +<?php + +namespace Drupal\system\Controller; + +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Cache\CacheableJsonResponse; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\RefinableCacheableDependencyInterface; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Menu\MenuLinkTreeInterface; +use Drupal\Core\Menu\MenuTreeParameters; +use Drupal\system\MenuInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Linkset controller. + * + * Provides a menu endpoint. + * + * @internal + * This class's API is internal and it is not intended for extension. + */ +final class LinksetController extends ControllerBase { + + /** + * Linkset constructor. + * + * @param \Drupal\Core\Menu\MenuLinkTreeInterface $menuTree + * The menu tree loader service. This is used to load a menu's link + * elements so that they can be serialized into a linkset response. + */ + public function __construct(protected readonly MenuLinkTreeInterface $menuTree) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('menu.link_tree')); + } + + /** + * Serve linkset requests. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * An HTTP request. + * @param \Drupal\system\MenuInterface $menu + * A menu for which to produce a linkset. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * A linkset response. + */ + public function process(Request $request, MenuInterface $menu) { + // Load the given menu's tree of elements. + $tree = $this->loadMenuTree($menu); + // Get the incoming request URI and parse it so the linkset can use a + // relative URL for the linkset anchor. + ['path' => $path, 'query' => $query] = parse_url($request->getUri()) + ['query' => FALSE]; + // Construct a relative URL. + $anchor = $path . (!empty($query) ? '?' . $query : ''); + $cacheability = CacheableMetadata::createFromObject($menu); + // Encode the menu tree as links in the application/linkset+json media type + // and add the machine name of the menu to which they belong. + $menu_id = $menu->id(); + $links = $this->toLinkTargetObjects($tree, $cacheability); + foreach ($links as $rel => $target_objects) { + $links[$rel] = array_map(function (array $target) use ($menu_id) { + // According to the Linkset specification, this member must be an array + // since the "machine-name" target attribute is non-standard. + // See https://tools.ietf.org/html/draft-ietf-httpapi-linkset-08#section-4.2.4.3 + return $target + ['machine-name' => [$menu_id]]; + }, $target_objects); + } + $linkset = !empty($tree) + ? [['anchor' => $anchor] + $links] + : []; + $data = ['linkset' => $linkset]; + // Set the response content-type header. + $headers = ['content-type' => 'application/linkset+json']; + $response = new CacheableJsonResponse($data, 200, $headers); + // Attach cacheability metadata to the response. + $response->addCacheableDependency($cacheability); + return $response; + } + + /** + * Encode a menu tree as link items and capture any cacheability metadata. + * + * This method recursively traverses the given menu tree to produce a flat + * array of link items encoded according the application/linkset+json + * media type. + * + * To preserve hierarchical information, the target attribute contains a + * `hierarchy` member. Its value is an array containing the position of a link + * within a particular sub-tree prepended by the positions of its ancestors, + * and can be used to reconstruct a hierarchical data structure. + * + * The reason that a `hierarchy` member is used instead of a `parent` or + * `children` member is because it is more compact, more suited to the linkset + * media type, and because it simplifies many menu operations. Specifically: + * + * 1. Creating a `parent` member would require each link to have an `id` + * in order to have something referenceable by the `parent` member. Reusing + * the link plugin IDs would not be viable because it would leak + * information about which modules are installed on the site. Therefore, + * this ID would have to be invented and would probably end up looking a + * lot like the `hierarchy` value. Finally, link IDs would encourage + * clients to hardcode the ID instead of using link relation types + * appropriately. + * 2. The linkset media type is not itself hierarchical. This means that + * `children` is infeasible without inventing our own Drupal-specific media + * type. + * 3. The `hierarchy` member can be used to efficiently perform tree + * operations that would otherwise be more complicated to implement. For + * example, by comparing the first X amount of hierarchy levels, you can + * find any subtree without writing recursive logic or complicated loops. + * Visit the URL below for more examples. + * + * The structure of a `hierarchy` value is defined below. + * + * A link which is a child of another link will always be prefixed by the + * exact value of their parent's hierarchy member. For example, if a link /bar + * is a child of a link /foo and /foo has a hierarchy member with the value + * ["1"], then the link /bar might have a hierarchy member with the value + * ["1", "0"]. The link /foo can be said to have depth 1, while the link + * /bar can be said to have depth 2. + * + * Links which have the same parent (or no parent) have their relative order + * preserved in the final component of the hierarchy value. + * + * According to the Linkset specification, each value in the hierarchy array + * must be a string. See https://tools.ietf.org/html/draft-ietf-httpapi-linkset-08#section-4.2.4.3 + * + * @param \Drupal\Core\Menu\MenuLinkTreeElement[] $tree + * A tree of menu elements. + * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $cacheability + * An object to capture any cacheability metadata. + * @param array $hierarchy_ancestors + * (Internal use only) The hierarchy value of the parent element + * if $tree is a subtree. Do not pass this value. + * + * @return array + * An array which can be JSON-encoded to represent the given link tree. + * + * @see https://www.drupal.org/project/decoupled_menus/issues/3204132#comment-14439385 + */ + protected function toLinkTargetObjects(array $tree, RefinableCacheableDependencyInterface $cacheability, $hierarchy_ancestors = []): array { + $links = []; + // Calling array_values() discards any key names so that $index will be + // numerical. + foreach (array_values($tree) as $index => $element) { + // Extract and preserve the access cacheability metadata. + $element_access = $element->access; + assert($element_access instanceof AccessResultInterface); + $cacheability->addCacheableDependency($element_access); + // If an element is not accessible, it should not be encoded. Its + // cacheability should be preserved regardless, which is why that is done + // outside of this conditional. + if ($element_access->isAllowed()) { + // Get and generate the URL of the link's target. This can create + // cacheability metadata also. + $url = $element->link->getUrlObject(); + $generated_url = $url->toString(TRUE); + $cacheability = $cacheability->addCacheableDependency($generated_url); + // Take the hierarchy value for the current element and append it + // to the link element parent's hierarchy value. See this method's + // docblock for more context on why this value is the way it is. + $hierarchy = $hierarchy_ancestors; + array_push($hierarchy, strval($index)); + $link_options = $element->link->getOptions(); + $link_attributes = ($link_options['attributes'] ?? []); + $link_rel = $link_attributes['rel'] ?? 'item'; + // Encode the link. + $link = [ + 'href' => $generated_url->getGeneratedUrl(), + // @todo should this use the "title*" key if it is internationalized? + // Follow up issue: + // https://www.drupal.org/project/decoupled_menus/issues/3280735 + 'title' => $element->link->getTitle(), + 'hierarchy' => $hierarchy, + ]; + $this->processCustomLinkAttributes($link, $link_attributes); + $links[$link_rel][] = $link; + // Recurse into the element's subtree. + if (!empty($element->subtree)) { + // Recursion! + $links = array_merge_recursive($links, $this->toLinkTargetObjects($element->subtree, $cacheability, $hierarchy)); + } + } + } + + return $links; + } + + /** + * Process custom link parameters. + * + * Since the values for attributes are dynamic and we can't + * guarantee that they adhere to the linkset specification, + * we do some custom processing as follows, + * 1. Transform all of them into an array if + * they are not already an array. + * 2. Transform all non-string values into strings + * (e.g. ["42"] instead of [42]) + * 3. Ignore (for now) any keys that are already specified. + * Namely: hreflang, media, type, title, and title*. + * 4. Ensure that custom names do not contain an + * asterisk and ignore them if they do. + * 5. These attributes require special handling. For instance, + * these parameters must be strings instead of an array of strings. + * + * NOTE: Values which are not object/array are cast to string. + * + * @param array $link + * Link structure. + * @param array $attributes + * Attributes available for the link. + */ + private function processCustomLinkAttributes(array &$link, array $attributes = []) { + $attribute_keys_to_ignore = [ + 'hreflang', + 'media', + 'type', + 'title', + 'title*', + ]; + + foreach ($attributes as $key => $value) { + if (in_array($key, $attribute_keys_to_ignore, TRUE)) { + continue; + } + // Skip the attribute key if it has an asterisk (*). + if (strpos($key, '*') !== FALSE) { + continue; + } + // Skip the value if it is an object. + if (is_object($value)) { + continue; + } + // See https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-linkset-03#section-4.2.4.3 + // Values for custom attributes must follow these rules, + // - Values MUST be array. + // - Each item in the array MUST be a string. + if (is_array($value)) { + $link[$key] = []; + foreach ($value as $val) { + if (is_object($val) || is_array($val)) { + continue; + } + $link[$key][] = (string) $val; + } + } + else { + $link[$key] = [(string) $value]; + } + } + } + + /** + * Loads a menu tree. + * + * @param \Drupal\system\MenuInterface $menu + * A menu for which a tree should be loaded. + * + * @return \Drupal\Core\Menu\MenuLinkTreeElement[] + * A menu link tree. + */ + protected function loadMenuTree(MenuInterface $menu) : array { + $parameters = new MenuTreeParameters(); + $parameters->onlyEnabledLinks(); + $parameters->setMinDepth(0); + $tree = $this->menuTree->load($menu->id(), $parameters); + $manipulators = [ + ['callable' => 'menu.default_tree_manipulators:checkAccess'], + ['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'], + ]; + return $this->menuTree->transform($tree, $manipulators); + } + +} diff --git a/core/modules/system/src/Form/MenuLinksetSettingsForm.php b/core/modules/system/src/Form/MenuLinksetSettingsForm.php new file mode 100644 index 000000000000..fd7663612e9e --- /dev/null +++ b/core/modules/system/src/Form/MenuLinksetSettingsForm.php @@ -0,0 +1,72 @@ +<?php + +namespace Drupal\system\Form; + +use Drupal\Core\Form\ConfigFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RouteBuilderInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Configure System settings for this site. + */ +class MenuLinksetSettingsForm extends ConfigFormBase { + + /** + * Constructs the routerBuilder service. + * + * @param \Drupal\Core\Routing\RouteBuilderInterface $routerBuilder + * The router builder service. + */ + public function __construct(protected readonly RouteBuilderInterface $routerBuilder) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('router.builder') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'menu_linkset_settings'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['system.feature_flags']; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['linkset']['enable_endpoint'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable the menu linkset endpoint'), + '#description' => $this->t('See the <a href="@docs-link">decoupled menus documentation</a> for more information.', [ + '@docs-link' => 'https://www.drupal.org/docs/develop/decoupled-drupal/decoupled-menus', + ]), + '#default_value' => $this->config('system.feature_flags')->get('linkset_endpoint'), + ]; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->config('system.feature_flags') + ->set('linkset_endpoint', $form_state->getValue('enable_endpoint')) + ->save(); + parent::submitForm($form, $form_state); + } + +} diff --git a/core/modules/system/src/Routing/MenuLinksetRoutes.php b/core/modules/system/src/Routing/MenuLinksetRoutes.php new file mode 100644 index 000000000000..bbafef4971ad --- /dev/null +++ b/core/modules/system/src/Routing/MenuLinksetRoutes.php @@ -0,0 +1,119 @@ +<?php + +namespace Drupal\system\Routing; + +use Drupal\Core\Config\ConfigCrudEvent; +use Drupal\Core\Config\ConfigEvents; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Routing\Route; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Routing\RouteSubscriberBase; +use Drupal\Core\Routing\RouteBuilderInterface; +use Symfony\Component\Routing\RouteCollection; + +/** + * Dynamically defines routes for menu linkset endpoints. + */ +class MenuLinksetRoutes extends RouteSubscriberBase implements ContainerInjectionInterface { + + /** + * An array of enabled authentication provider IDs. + * + * @var string[] + */ + protected readonly array $providerIds; + + /** + * EventSubscriber constructor. + * + * @param string[] $authenticationProviders + * An array of authentication providers, keyed by ID. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + * @param \Drupal\Core\Routing\RouteBuilderInterface $routeBuilder + * The route builder. + */ + public function __construct(array $authenticationProviders, protected readonly ConfigFactoryInterface $configFactory, protected readonly RouteBuilderInterface $routeBuilder) { + $this->providerIds = array_keys($authenticationProviders); + } + + /** + * Alter routes. + * + * If the endpoint is configured to be enabled, dynamically enable all + * authentication providers on this module's routes since they cannot be known + * in advance. + * + * @param \Symfony\Component\Routing\RouteCollection $collection + * A collection of routes. + */ + public function alterRoutes(RouteCollection $collection) { + if ($this->configFactory->get('system.feature_flags')->get('linkset_endpoint')) { + $collection->get('system.menu.linkset')->setOption('_auth', $this->providerIds); + } + } + + /** + * {@inheritdoc} + */ + public function onConfigSave(ConfigCrudEvent $event) { + $saved_config = $event->getConfig(); + if ($saved_config->getName() === 'system.feature_flags' && $event->isChanged('linkset_endpoint')) { + $this->routeBuilder->setRebuildNeeded(); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events = parent::getSubscribedEvents(); + // Run after the route alter event subscriber. + $events[ConfigEvents::SAVE][] = ['onConfigSave', 0]; + return $events; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->getParameter('authentication_providers'), + $container->get('config.factory'), + $container->get('router.builder') + ); + } + + /** + * Returns an array of route objects. + * + * @return \Symfony\Component\Routing\Route[] + * An array of route objects. + */ + public function routes() { + $routes = []; + + // Only enable linkset routes if the related config option is enabled. + if ($this->configFactory->get('system.feature_flags')->get('linkset_endpoint')) { + $routes['system.menu.linkset'] = new Route( + '/system/menu/{menu}/linkset', + [ + '_controller' => 'Drupal\system\Controller\LinksetController::process', + ], + [ + '_access' => 'TRUE', + ], + [ + 'parameters' => [ + 'menu' => [ + 'type' => 'entity:menu', + ], + ], + ] + ); + } + return $routes; + } + +} diff --git a/core/modules/system/system.links.menu.yml b/core/modules/system/system.links.menu.yml index ab592cf40316..b46934aa25d0 100644 --- a/core/modules/system/system.links.menu.yml +++ b/core/modules/system/system.links.menu.yml @@ -154,3 +154,10 @@ system.status: description: 'Get a status report about your site''s operation.' route_name: system.status weight: -60 + +system.linkset_settings: + title: Menu Linkset Settings + description: Enable or disable the menu linkset endpoint + parent: system.admin_config_services + route_name: system.linkset_settings + weight: 10 diff --git a/core/modules/system/system.post_update.php b/core/modules/system/system.post_update.php index 9f130d616767..f6410d1b8098 100644 --- a/core/modules/system/system.post_update.php +++ b/core/modules/system/system.post_update.php @@ -43,3 +43,11 @@ function system_removed_post_updates() { 'system_post_update_enable_provider_database_driver' => '10.0.0', ]; } + +/** + * Add new menu linkset endpoint setting. + */ +function system_post_update_linkset_settings() { + $config = \Drupal::configFactory()->getEditable('system.feature_flags'); + $config->set('linkset_endpoint', FALSE)->save(); +} diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml index 362763fa1823..9f8f5548dc87 100644 --- a/core/modules/system/system.routing.yml +++ b/core/modules/system/system.routing.yml @@ -521,5 +521,14 @@ system.csrftoken: requirements: _access: 'TRUE' +system.linkset_settings: + path: '/admin/config/services/linkset' + defaults: + _title: 'Menu Linkset Settings' + _form: 'Drupal\system\Form\MenuLinksetSettingsForm' + requirements: + _permission: 'administer site configuration' + route_callbacks: - '\Drupal\system\Routing\AssetRoutes::routes' + - '\Drupal\system\Routing\MenuLinksetRoutes::routes' diff --git a/core/modules/system/system.services.yml b/core/modules/system/system.services.yml index b1d6bdfa78a0..dba427f4b3ed 100644 --- a/core/modules/system/system.services.yml +++ b/core/modules/system/system.services.yml @@ -64,3 +64,8 @@ services: arguments: ['@system.sa_fetcher'] tags: - { name: event_subscriber } + system.menus.route_subscriber: + class: Drupal\system\Routing\MenuLinksetRoutes + arguments: ['%authentication_providers%', '@config.factory', '@router.builder'] + tags: + - { name: event_subscriber } diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-aa.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-aa.json new file mode 100644 index 000000000000..88aa47a02c96 --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-aa.json @@ -0,0 +1,33 @@ +{ + "linkset": [ + { + "anchor": "/aa/system/menu/main/linkset", + "item": [ + { + "href": "/aa", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/aa/node/1", + "title": "aa|A multi-lingual-node", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/aa/node/2", + "title": "aa|Second multi-lingual-node", + "hierarchy": ["2"], + "machine-name": ["main"] + }, + { + "href": "/aa/node/3", + "title": "aa|Third multi-lingual-node", + "hierarchy": ["3"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-bb.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-bb.json new file mode 100644 index 000000000000..258d0db301b4 --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-bb.json @@ -0,0 +1,33 @@ +{ + "linkset": [ + { + "anchor": "/bb/system/menu/main/linkset", + "item": [ + { + "href": "/bb", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/bb/node/1", + "title": "bb|A multi-lingual-node", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/bb/node/2", + "title": "bb|Second multi-lingual-node", + "hierarchy": ["2"], + "machine-name": ["main"] + }, + { + "href": "/bb/node/3", + "title": "bb|Third multi-lingual-node", + "hierarchy": ["3"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-cc.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-cc.json new file mode 100644 index 000000000000..49e6e027b05c --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-cc.json @@ -0,0 +1,33 @@ +{ + "linkset": [ + { + "anchor": "/cc/system/menu/main/linkset", + "item": [ + { + "href": "/cc", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/cc/node/1", + "title": "cc|A multi-lingual-node", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/cc/node/2", + "title": "aa|Second multi-lingual-node", + "hierarchy": ["2"], + "machine-name": ["main"] + }, + { + "href": "/cc/node/3", + "title": "aa|Third multi-lingual-node", + "hierarchy": ["3"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-dd.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-dd.json new file mode 100644 index 000000000000..29591a852c12 --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-dd.json @@ -0,0 +1,33 @@ +{ + "linkset": [ + { + "anchor": "/dd/system/menu/main/linkset", + "item": [ + { + "href": "/dd", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/dd/node/1", + "title": "A multi-lingual-node", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/dd/node/2", + "title": "Second multi-lingual-node", + "hierarchy": ["2"], + "machine-name": ["main"] + }, + { + "href": "/dd/node/3", + "title": "Third multi-lingual-node", + "hierarchy": ["3"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-default.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-default.json new file mode 100644 index 000000000000..ac8a24e6d9c1 --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main-multilingual-default.json @@ -0,0 +1,33 @@ +{ + "linkset": [ + { + "anchor": "/system/menu/main/linkset", + "item": [ + { + "href": "/", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/node/1", + "title": "A multi-lingual-node", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/node/2", + "title": "Second multi-lingual-node", + "hierarchy": ["2"], + "machine-name": ["main"] + }, + { + "href": "/node/3", + "title": "Third multi-lingual-node", + "hierarchy": ["3"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json b/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json new file mode 100644 index 000000000000..122b73b49a09 --- /dev/null +++ b/core/modules/system/tests/fixtures/linkset/linkset-menu-main.json @@ -0,0 +1,47 @@ +{ + "linkset": [ + { + "anchor": "/system/menu/main/linkset", + "item": [ + { + "href": "/", + "title": "Home", + "hierarchy": ["0"], + "machine-name": ["main"] + }, + { + "href": "/about", + "title": "About us", + "hierarchy": ["1"], + "machine-name": ["main"] + }, + { + "href": "/about/custom-attributes", + "title": "Custom attributes test page", + "hierarchy": ["1", "0"], + "class": [ + "foo", + "bar", + "1729", + "1", + "", + "0", + "-1", + "3.141592" + ], + "data-baz": [ + "42" + ], + "¯\\_(ツ)_/¯": ["ok"], + "machine-name": ["main"] + }, + { + "href": "/about/name", + "title": "Our name", + "hierarchy": ["1","1"], + "machine-name": ["main"] + } + ] + } + ] +} diff --git a/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.info.yml b/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.info.yml new file mode 100644 index 000000000000..40b4a3005276 --- /dev/null +++ b/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.info.yml @@ -0,0 +1,8 @@ +name: 'Decoupled menus test module' +type: module +description: 'Support module for decoupled_menus endpoint.' +package: Testing +version: VERSION +dependencies: + drupal:rest + drupal:user diff --git a/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.module b/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.module new file mode 100644 index 000000000000..bbe6cb1052dd --- /dev/null +++ b/core/modules/system/tests/modules/decoupled_menus_test/decoupled_menus_test.module @@ -0,0 +1,17 @@ +<?php + +/** + * @file + * Support module for decoupled_menus endpoint in tests. + */ + +declare(strict_types=1); + +/** + * Implements hook_menu_links_discovered_alter(). + */ +function decoupled_menus_test_menu_links_discovered_alter(&$links) { + // Sets a custom link relation type on a menu item. + // @see https://tools.ietf.org/id/draft-pot-authentication-link-01.html + $links['user.page']['options']['attributes']['rel'] = 'authenticated-as'; +} diff --git a/core/modules/system/tests/src/Functional/Form/MenuLinksetSettingsFormTest.php b/core/modules/system/tests/src/Functional/Form/MenuLinksetSettingsFormTest.php new file mode 100644 index 000000000000..743fe1ffe51a --- /dev/null +++ b/core/modules/system/tests/src/Functional/Form/MenuLinksetSettingsFormTest.php @@ -0,0 +1,75 @@ +<?php + +namespace Drupal\Tests\system\Functional\Form; + +use Drupal\Tests\BrowserTestBase; + +/** + * Tests the the menu_linkset_settings form. + * + * @group Form + */ +class MenuLinksetSettingsFormTest extends BrowserTestBase { + + /** + * Modules to enable. + * + * @var array + */ + protected static $modules = ['system']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected $profile = 'minimal'; + + /** + * A user account to modify the menu linkset settings form. + * + * @var \Drupal\user\UserInterface + */ + protected $adminAccount; + + /** + * Tests the menu_linkset_settings form. + */ + public function testMenuLinksetSettingsForm() { + // Users without the appropriate permissions should not be able to access. + $this->drupalGet('admin/config/services/linkset'); + $this->assertSession()->pageTextContains('Access denied'); + + // Users with permission should be able to access the form. + $permissions = ['administer site configuration']; + $this->adminAccount = $this->setUpCurrentUser([ + 'name' => 'system_admin', + 'pass' => 'adminPass', + ], $permissions); + $this->drupalLogin($this->adminAccount); + $this->drupalGet('admin/config/services/linkset'); + $this->assertSession() + ->elementExists('css', '#edit-actions > input.button--primary'); + + // Confirm endpoint can be enabled. + $this->assertSession()->fieldExists('edit-enable-endpoint')->check(); + $this->submitForm([], 'Save configuration'); + $this->assertSession() + ->pageTextContains('The configuration options have been saved.'); + $this->assertSession()->fieldExists('edit-enable-endpoint')->isChecked(); + $is_endpoint_enabled = $this->config('system.feature_flags')->get('linkset_endpoint'); + $this->assertTrue($is_endpoint_enabled, 'Endpoint is enabled.'); + + // Confirm endpoint can be disabled. + $this->assertSession()->fieldExists('edit-enable-endpoint')->uncheck(); + $this->submitForm([], 'Save configuration'); + $this->assertSession() + ->pageTextContains('The configuration options have been saved.'); + $is_endpoint_enabled = $this->config('system.feature_flags')->get('linkset_endpoint'); + $this->assertFalse($is_endpoint_enabled, 'Endpoint is disabled.'); + } + +} diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php new file mode 100644 index 000000000000..d01a3f634473 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerMultiLingualTest.php @@ -0,0 +1,223 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Menu; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Url; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Entity\ContentLanguageSettings; +use Drupal\Core\Language\LanguageInterface; +use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationSelected; +use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrl; + +/** + * Tests the behavior of the linkset controller in multilingual setup. + * + * @group decoupled_menus + * + * @see https://tools.ietf.org/html/draft-ietf-httpapi-linkset-00 + */ +final class LinksetControllerMultiLingualTest extends LinksetControllerTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected $profile = 'minimal'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'language', + ]; + + /** + * An HTTP kernel. + * + * Used to send a test request to the controller under test and validate its + * response. + * + * @var \Symfony\Component\HttpKernel\HttpKernelInterface + */ + protected $httpKernel; + + /** + * A user account to author test content. + * + * @var \Drupal\user\UserInterface + */ + protected $authorAccount; + + /** + * Test set up. + * + * Installs necessary database schemas, then creates test content and menu + * items. The concept of this set up is to replicate a typical site's menus. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function setUp(): void { + parent::setUp(); + // Create Basic page node type. + $this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']); + // Add some custom languages. + foreach (['aa', 'bb', 'cc', 'dd'] as $index => $language_code) { + ConfigurableLanguage::create([ + 'id' => $language_code, + 'label' => $this->randomMachineName(), + 'weight' => $index, + ])->save(); + } + // Set up an admin user with appropriate permissions. + $admin_user = $this->drupalCreateUser([ + 'view own unpublished content', + 'administer languages', + 'administer content types', + 'access administration pages', + 'create page content', + 'edit own page content', + ]); + $this->drupalLogin($admin_user); + + $config = $this->config('language.types'); + $config->set('configurable', [LanguageInterface::TYPE_INTERFACE]); + $config->set('negotiation.language_content.enabled', [ + LanguageNegotiationUrl::METHOD_ID => 1, + LanguageNegotiationSelected::METHOD_ID => 1, + ]); + $config->save(); + + \Drupal::configFactory()->getEditable('language.negotiation') + ->set('url.prefixes.aa', 'aa') + ->set('url.prefixes.bb', 'bb') + ->set('url.prefixes.cc', 'cc') + ->set('selected_langcode', 'dd') + ->save(); + + // Set default language code for content type page to 'dd'. + ContentLanguageSettings::loadByEntityTypeBundle('node', 'page') + ->setDefaultLangcode('dd') + ->setLanguageAlterable(TRUE) + ->save(); + // Set default language code to for menu_link_content 'dd'. + ContentLanguageSettings::loadByEntityTypeBundle('menu_link_content', 'menu_link_content') + ->setDefaultLangcode('dd') + ->setLanguageAlterable(TRUE) + ->save(); + $this->config('system.feature_flags') + ->set('linkset_endpoint', TRUE) + ->save(TRUE); + // Using rebuildIfNeeded here to implicitly test that router is only rebuilt + // when necessary. + $this->rebuildIfNeeded(); + $this->drupalLogout(); + + $permissions = [ + 'view own unpublished content', + ]; + $this->authorAccount = $this->setUpCurrentUser([ + 'name' => 'author', + 'pass' => 'authorPass', + ], $permissions); + + // Generate some data which we can test against. + $home_page_link = $this->createMenuItem([ + 'title' => 'Home', + 'description' => 'Links to the home page.', + 'link' => 'internal:/<front>', + 'weight' => 0, + 'menu_name' => 'main', + ]); + + // Multilingual test. + $multi_lingual_node = $this->createNode([ + 'nid' => 1, + 'title' => 'A multi-lingual-node', + 'type' => 'page', + 'path' => '/multi-lingual-node', + ]); + $multi_lingual_menu_item = $this->createMenuItem([ + 'title' => 'A multi-lingual-node', + 'link' => 'entity:node/' . (int) $multi_lingual_node->id(), + 'menu_name' => 'main', + 'weight' => $home_page_link->getWeight() + 1, + ]); + foreach (['aa', 'bb', 'cc'] as $language_code) { + $multi_lingual_menu_item->addTranslation($language_code, [ + 'title' => $language_code . '|' . 'A multi-lingual-node', + ]); + $multi_lingual_menu_item->save(); + } + // Multilingual Menu item with missing language using `entity:` route. + $multi_lingual_node = $this->createNode([ + 'nid' => 2, + 'title' => 'A multi-lingual-node', + 'type' => 'page', + 'path' => '/multi-lingual-node-two', + ]); + $multi_lingual_menu_item = $this->createMenuItem([ + 'title' => 'Second multi-lingual-node', + 'link' => 'entity:node/' . (int) $multi_lingual_node->id(), + 'menu_name' => 'main', + 'weight' => $home_page_link->getWeight() + 2, + ]); + foreach (['aa', 'bb'] as $language_code) { + $multi_lingual_menu_item->addTranslation($language_code, [ + 'title' => $language_code . '|' . 'Second multi-lingual-node', + ]); + $multi_lingual_menu_item->save(); + } + // Multilingual Menu item with missing language using `internal` route. + $multi_lingual_node = $this->createNode([ + 'nid' => 3, + 'title' => 'A multi-lingual-node', + 'type' => 'page', + 'path' => '/multi-lingual-node-three', + ]); + $multi_lingual_menu_item = $this->createMenuItem([ + 'title' => 'Third multi-lingual-node', + 'link' => 'internal:/node/' . (int) $multi_lingual_node->id(), + 'menu_name' => 'main', + 'weight' => $home_page_link->getWeight() + 3, + ]); + foreach (['aa', 'bb'] as $language_code) { + $multi_lingual_menu_item->addTranslation($language_code, [ + 'title' => $language_code . '|' . 'Third multi-lingual-node', + ]); + $multi_lingual_menu_item->save(); + } + $this->httpKernel = $this->container->get('http_kernel'); + } + + /** + * Test core functions of the linkset for multilingual behaviour. + * + * @throws \Exception + */ + public function testBasicMultilingualFunctions() { + foreach (['aa', 'bb', 'cc', 'dd'] as $language_code) { + $expected_linkset = $this->getReferenceLinksetDataFromFile(__DIR__ . '/../../../fixtures/linkset/linkset-menu-main-multilingual-' . $language_code . '.json'); + $response = $this->doRequest('GET', Url::fromUri('base:/' . $language_code . '/system/menu/main/linkset')); + $this->assertSame($expected_linkset, Json::decode((string) $response->getBody())); + } + } + + /** + * Test core functions of the linkset for multilingual behaviour. + * + * @throws \Exception + */ + public function testDefaultMultilingualFunctions() { + $expected_linkset = $this->getReferenceLinksetDataFromFile(__DIR__ . '/../../../fixtures/linkset/linkset-menu-main-multilingual-default.json'); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $this->assertSame($expected_linkset, Json::decode((string) $response->getBody())); + } + +} diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php new file mode 100644 index 000000000000..b786b2f84391 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTest.php @@ -0,0 +1,334 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Menu; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Url; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; + +/** + * Tests the behavior of the linkset controller. + * + * The purpose of this test is to validate that the a typical menu can be + * correctly serialized as using the application/linkset+json media type. + * + * @group decoupled_menus + * + * @see https://tools.ietf.org/html/draft-ietf-httpapi-linkset-00 + */ +final class LinksetControllerTest extends LinksetControllerTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected $profile = 'minimal'; + + /** + * An HTTP kernel. + * + * Used to send a test request to the controller under test and validate its + * response. + * + * @var \Symfony\Component\HttpKernel\HttpKernelInterface + */ + protected $httpKernel; + + /** + * A user account to author test content. + * + * @var \Drupal\user\UserInterface + */ + protected $authorAccount; + + /** + * Test set up. + * + * Installs necessary database schemas, then creates test content and menu + * items. The concept of this set up is to replicate a typical site's menus. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function setUp(): void { + parent::setUp(); + + $permissions = ['view own unpublished content']; + $this->authorAccount = $this->setUpCurrentUser([ + 'name' => 'author', + 'pass' => 'authorPass', + ], $permissions); + + NodeType::create([ + 'type' => 'page', + ])->save(); + + $home_page_link = $this->createMenuItem([ + 'title' => 'Home', + 'description' => 'Links to the home page.', + 'link' => 'internal:/<front>', + 'weight' => 0, + 'menu_name' => 'main', + ]); + + $about_us_page = $this->createNode([ + 'nid' => 1, + 'title' => 'About us', + 'type' => 'page', + 'path' => '/about', + ]); + $about_us_link = $this->createMenuItem([ + 'title' => 'About us', + 'description' => 'Links to the about us page.', + 'link' => 'entity:node/' . (int) $about_us_page->id(), + 'weight' => $home_page_link->getWeight() + 1, + 'menu_name' => 'main', + ]); + + $our_name_page = $this->createNode([ + 'nid' => 2, + 'title' => 'Our name', + 'type' => 'page', + 'path' => '/about/name', + ]); + $this->createMenuItem([ + 'title' => 'Our name', + 'description' => 'Links to the page which describes the origin of the organization name.', + 'link' => 'entity:node/' . (int) $our_name_page->id(), + 'menu_name' => 'main', + 'parent' => $about_us_link->getPluginId(), + ]); + + $custom_attributes_test_page = $this->createNode([ + 'nid' => 3, + 'title' => 'Custom attributes test page', + 'type' => 'page', + 'path' => '/about/custom-attributes', + ]); + $options = [ + 'attributes' => [ + 'class' => [ + 'foo', + 'bar', + 1729, + TRUE, + FALSE, + 0, + -1, + 3.141592, + ], + 'data-baz' => '42', + '*ignored' => '¯\_(ツ)_/¯', + '¯\_(ツ)_/¯' => 'ok', + "hreflang" => "en-mx", + "media" => "???", + "type" => "???", + "title" => "???", + "title*" => "???", + ], + ]; + $this->createMenuItem([ + 'title' => 'Custom attributes test page', + 'description' => 'Links to the page which describes the origin of the organization name.', + 'link' => 'entity:node/' . (int) $custom_attributes_test_page->id(), + 'menu_name' => 'main', + 'parent' => $about_us_link->getPluginId(), + ], $options); + + $this->httpKernel = $this->container->get('http_kernel'); + } + + /** + * Test core functions of the linkset endpoint. + * + * Not intended to test every feature of the endpoint, only the most basic + * functionality. + * + * The expected linkset also ensures that path aliasing is working properly. + * + * @throws \Exception + */ + public function testBasicFunctions() { + $this->enableEndpoint(TRUE); + $expected_linkset = $this->getReferenceLinksetDataFromFile(__DIR__ . '/../../../fixtures/linkset/linkset-menu-main.json'); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $this->assertSame('application/linkset+json', $response->getHeaderLine('content-type')); + $this->assertSame($expected_linkset, Json::decode((string) $response->getBody())); + $this->doRequest('GET', Url::fromUri('base:/system/menu/missing/linkset'), 404); + } + + /** + * Test the cacheability of the linkset endpoint. + * + * This test's purpose is to ensure that the menu linkset response is properly + * cached. It does this by sending a request and validating it has a cache + * miss and the correct cacheability meta, then by sending the same request to + * assert a cache hit. Finally, a new menu item is created to ensure that the + * cached response is properly invalidated. + */ + public function testCacheability() { + $this->enableEndpoint(TRUE); + $expected_cacheability = new CacheableMetadata(); + $expected_cacheability->addCacheContexts([ + 'user.permissions', + ]); + $expected_cacheability->addCacheTags([ + 'config:system.menu.main', + 'config:user.role.anonymous', + 'http_response', + 'node:1', + 'node:2', + 'node:3', + ]); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $this->assertDrupalResponseCacheability('MISS', $expected_cacheability, $response); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $this->assertDrupalResponseCacheability('HIT', $expected_cacheability, $response); + // Create a new menu item to invalidate the cache. + $duplicate_title = 'About us (duplicate)'; + $this->createMenuItem([ + 'title' => $duplicate_title, + 'description' => 'Links to the about us page again.', + 'link' => 'entity:node/1', + 'menu_name' => 'main', + ]); + // Redo the request. + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + // Assert that the cache has been invalidated. + $this->assertDrupalResponseCacheability('MISS', $expected_cacheability, $response); + // Then ensure that the new menu link is in the response. + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertContains($duplicate_title, $titles); + } + + /** + * Test the access control functionality of the linkset endpoint. + * + * By testing with different current users (Anonymous included) against the + * user account menu, this test ensures that the menu endpoint respects route + * access controls. E.g. it does not output links to which the current user + * does not have access (if it can be determined). + */ + public function testAccess() { + $this->enableEndpoint(TRUE); + $expected_cacheability = new CacheableMetadata(); + $expected_cacheability->addCacheContexts(['user.permissions']); + $expected_cacheability->addCacheTags([ + 'config:system.menu.main', + 'config:user.role.anonymous', + 'http_response', + 'node:1', + 'node:2', + 'node:3', + ]); + // Warm the cache, then get a response and ensure it was warmed. + $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + $this->assertDrupalResponseCacheability('HIT', $expected_cacheability, $response); + // Ensure the "Our name" menu link is visible. + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertContains('Our name', $titles); + // Now, unpublish the target node. + $our_name_page = Node::load(2); + assert($our_name_page instanceof NodeInterface); + $our_name_page->setUnpublished()->save(); + // Redo the request. + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset')); + // Assert that the cache was invalidated. + $this->assertDrupalResponseCacheability('MISS', $expected_cacheability, $response); + // Ensure the "Our name" menu link is no longer visible. + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertNotContains('Our name', $titles); + // Redo the request, but authenticate as the unpublished page's author. + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset'), 200, $this->authorAccount); + $expected_cacheability = new CacheableMetadata(); + $expected_cacheability->addCacheContexts(['user']); + $expected_cacheability->addCacheTags([ + 'config:system.menu.main', + 'http_response', + 'node:1', + 'node:2', + 'node:3', + ]); + $this->assertDrupalResponseCacheability(FALSE, $expected_cacheability, $response); + // Ensure the "Our name" menu link is visible. + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertContains('Our name', $titles); + } + + /** + * Tests that the user account menu behaves as it should. + * + * The account menu is a good test case because it provides a restricted, + * YAML-defined link ("My account") and a dynamic code-defined link + * ("Log in/out") + */ + public function testUserAccountMenu() { + $this->enableEndpoint(TRUE); + $expected_cacheability = new CacheableMetadata(); + $expected_cacheability->addCacheContexts([ + 'user.permissions', + 'user.roles:authenticated', + ]); + $expected_cacheability->addCacheTags([ + 'config:system.menu.account', + 'config:user.role.anonymous', + 'http_response', + ]); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset')); + $this->assertDrupalResponseCacheability('MISS', $expected_cacheability, $response); + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertContains('Log in', $titles); + $this->assertNotContains('Log out', $titles); + $this->assertNotContains('My account', $titles); + // Redo the request, but with an authenticated user. + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset'), 200, $this->authorAccount); + // The expected cache tags must be updated. + $expected_cacheability->setCacheTags([ + 'config:system.menu.account', + 'http_response', + ]); + // Authenticated requests do not use the page cache, so a "HIT" or "MISS" + // isn't expected either. + $this->assertDrupalResponseCacheability(FALSE, $expected_cacheability, $response); + $link_items = Json::decode((string) $response->getBody())['linkset'][0]['item']; + $titles = array_column($link_items, 'title'); + $this->assertContains('Log out', $titles); + $this->assertContains('My account', $titles); + $this->assertNotContains('Log in', $titles); + } + + /** + * Tests that menu items can use a custom link relation. + */ + public function testCustomLinkRelation() { + $this->enableEndpoint(TRUE); + $this->assertTrue($this->container->get('module_installer')->install(['decoupled_menus_test'], TRUE), 'Installed modules.'); + $response = $this->doRequest('GET', Url::fromUri('base:/system/menu/account/linkset'), 200, $this->authorAccount); + $link_context_object = Json::decode((string) $response->getBody())['linkset'][0]; + $this->assertContains('authenticated-as', array_keys($link_context_object)); + $my_account_link = $link_context_object['authenticated-as'][0]; + $this->assertSame('My account', $my_account_link['title']); + } + + /** + * Test that api route does not exist if the config option is disabled. + */ + public function testDisabledEndpoint() { + $this->doRequest('GET', Url::fromUri('base:/system/menu/main/linkset'), 404); + } + +} diff --git a/core/modules/system/tests/src/Functional/Menu/LinksetControllerTestBase.php b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTestBase.php new file mode 100644 index 000000000000..d01aff26108d --- /dev/null +++ b/core/modules/system/tests/src/Functional/Menu/LinksetControllerTestBase.php @@ -0,0 +1,182 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\system\Functional\Menu; + +use Drupal\Component\Serialization\Json; +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Url; +use Drupal\menu_link_content\Entity\MenuLinkContent; +use Drupal\menu_link_content\MenuLinkContentInterface; +use Drupal\Tests\ApiRequestTrait; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\user\UserInterface; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\RequestOptions; + +/** + * A base class for implementing LinksetController tests. + * + * Provides general purpose helper methods that are commonly needed + * when writing LinksetController tests. + * - Perform request against the linkset endpoint. + * - Create Menu items. + * + * For a full list, refer to the methods of this class. + * + * @group decoupled_menus + * + * @see https://tools.ietf.org/html/draft-ietf-httpapi-linkset-00 + */ +abstract class LinksetControllerTestBase extends BrowserTestBase { + + use ApiRequestTrait; + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'basic_auth', + 'link', + 'path_alias', + 'path', + 'user', + 'menu_link_content', + 'node', + 'page_cache', + 'dynamic_page_cache', + ]; + + /** + * Sends a request to the kernel and makes basic response assertions. + * + * Only to be used when the expected response is a linkset response. + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param int $expected_status + * The expected status code. + * @param \Drupal\user\UserInterface $account + * A user account whose credentials should be used to authenticate the + * request. + * + * @return \GuzzleHttp\Psr7\Response + * The response object. + */ + protected function doRequest(string $method, Url $url, $expected_status = 200, UserInterface $account = NULL): Response { + $request_options = []; + if (!is_null($account)) { + $credentials = $account->name->value . ':' . $account->passRaw; + $request_options[RequestOptions::HEADERS] = [ + 'Authorization' => 'Basic ' . base64_encode($credentials), + ]; + } + $response = $this->makeApiRequest($method, $url, $request_options); + $this->assertSame($expected_status, $response->getStatusCode(), (string) $response->getBody()); + return $response; + } + + /** + * Helper to assert a cacheable value matches an expectation. + * + * @param string|false $expect_cache + * 'HIT', 'MISS', or FALSE. Asserts the value of the X-Drupal-Cache header. + * FALSE if the page cache is not applicable. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $expected_metadata + * The expected cacheability metadata. + * @param \GuzzleHttp\Psr7\Response $response + * The response on which to assert cacheability. + */ + protected function assertDrupalResponseCacheability($expect_cache, CacheableDependencyInterface $expected_metadata, Response $response) { + $this->assertTrue(in_array($expect_cache, ['HIT', 'MISS', FALSE], TRUE), 'Cache is HIT, MISS, FALSE.'); + $this->assertSame($expected_metadata->getCacheContexts(), explode(' ', $response->getHeaderLine('X-Drupal-Cache-Contexts'))); + $this->assertSame($expected_metadata->getCacheTags(), explode(' ', $response->getHeaderLine('X-Drupal-Cache-Tags'))); + $max_age_message = $expected_metadata->getCacheMaxAge(); + if ($max_age_message === 0) { + $max_age_message = '0 (Uncacheable)'; + } + elseif ($max_age_message === -1) { + $max_age_message = '-1 (Permanent)'; + } + $this->assertSame($max_age_message, $response->getHeaderLine('X-Drupal-Cache-Max-Age')); + if ($expect_cache) { + $this->assertSame($expect_cache, $response->getHeaderLine('X-Drupal-Cache')); + } + } + + /** + * Creates, saves, and returns a new menu link content entity. + * + * @param array $values + * Menu field values. + * @param array $options + * Menu options. + * + * @return \Drupal\menu_link_content\MenuLinkContentInterface + * The newly created menu link content entity. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * + * @see \Drupal\menu_link_content\MenuLinkContentInterface::create() + */ + protected function createMenuItem(array $values, array $options = []): MenuLinkContentInterface { + if (!empty($options)) { + $values['link'] = ['uri' => $values['link'], 'options' => $options]; + } + $link_content = MenuLinkContent::create($values); + assert($link_content instanceof MenuLinkContentInterface); + $link_content->save(); + return $link_content; + } + + /** + * Enables or disables the menu linkset endpoint. + * + * @param bool $enabled + * Whether the endpoint should be enabled. + */ + protected function enableEndpoint(bool $enabled) { + $this->config('system.feature_flags') + ->set('linkset_endpoint', $enabled) + ->save(TRUE); + // Using rebuildIfNeeded here to implicitly test that router is only rebuilt + // when necessary. + \Drupal::service('router.builder')->rebuildIfNeeded(); + } + + /** + * Retrieve reference linkset controller output adjusted for proper base url. + * + * @param string $filename + * Name of the file to read. + * + * @return mixed + * The Json representation of the reference data in the file. + */ + protected function getReferenceLinksetDataFromFile(string $filename) { + $data = Json::decode(file_get_contents($filename)); + // Ensure that the URLs are correct if Drupal is being served from a + // subdirectory. + $data['linkset'][0]['anchor'] = Url::fromUri('base:' . $data['linkset'][0]['anchor'])->toString(); + foreach ($data['linkset'][0]['item'] as &$item) { + $item['href'] = Url::fromUri('base:' . $item['href'])->toString(); + } + return $data; + } + + /** + * Rebuild the router only if needed. + */ + public function rebuildIfNeeded() { + /** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */ + $router_builder = $this->container->get('router.builder'); + $router_builder->rebuildIfNeeded(); + } + +} diff --git a/core/modules/system/tests/src/Functional/Update/MenuLinksetSettingsUpdateTest.php b/core/modules/system/tests/src/Functional/Update/MenuLinksetSettingsUpdateTest.php new file mode 100644 index 000000000000..0808fdd55014 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Update/MenuLinksetSettingsUpdateTest.php @@ -0,0 +1,41 @@ +<?php + +namespace Drupal\Tests\system\Functional\Update; + +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +/** + * Tests creation of system linkset settings. + * + * @see system_post_update_linkset_settings() + * + * @group Update + */ +class MenuLinksetSettingsUpdateTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../fixtures/update/drupal-9.4.0.bare.standard.php.gz', + ]; + } + + /** + * Tests system_post_update_linkset_settings(). + */ + public function testSystemPostUpdateLinksetSettings() { + // Ensure config is not present. + $config = $this->config('system.feature_flags'); + $this->assertTrue($config->isNew()); + + $this->runUpdates(); + + // Confirm that config was created and the endpoint is disabled. + $updated_config = $this->config('system.feature_flags'); + $this->assertFalse($updated_config->isNew()); + $this->assertFalse($updated_config->get('linkset_endpoint')); + } + +} diff --git a/core/tests/Drupal/Tests/ApiRequestTrait.php b/core/tests/Drupal/Tests/ApiRequestTrait.php new file mode 100644 index 000000000000..9a5431a2c7e7 --- /dev/null +++ b/core/tests/Drupal/Tests/ApiRequestTrait.php @@ -0,0 +1,74 @@ +<?php + +namespace Drupal\Tests; + +use Behat\Mink\Driver\BrowserKitDriver; +use Drupal\Core\Url; +use GuzzleHttp\RequestOptions; + +/** + * Boilerplate for API Functional tests' HTTP requests. + * + * @internal + */ +trait ApiRequestTrait { + + /** + * Performs an HTTP request. Wraps the Guzzle HTTP client. + * + * Why wrap the Guzzle HTTP client? Because we want to keep the actual test + * code as simple as possible, and hence not require them to specify the + * 'http_errors = FALSE' request option, nor do we want them to have to + * convert Drupal Url objects to strings. + * + * We also don't want to follow redirects automatically, to ensure these tests + * are able to detect when redirects are added or removed. + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + * + * @return \Psr\Http\Message\ResponseInterface + * The response. + * + * @see \GuzzleHttp\ClientInterface::request() + */ + protected function makeApiRequest($method, Url $url, array $request_options) { + $this->refreshVariables(); + $request_options[RequestOptions::HTTP_ERRORS] = FALSE; + $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE; + $request_options = $this->decorateWithXdebugCookie($request_options); + $client = $this->getSession()->getDriver()->getClient()->getClient(); + return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options); + } + + /** + * Adds the Xdebug cookie to the request options. + * + * @param array $request_options + * The request options. + * + * @return array + * Request options updated with the Xdebug cookie if present. + */ + protected function decorateWithXdebugCookie(array $request_options) { + $session = $this->getSession(); + $driver = $session->getDriver(); + if ($driver instanceof BrowserKitDriver) { + $client = $driver->getClient(); + foreach ($client->getCookieJar()->all() as $cookie) { + if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) { + $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue(); + } + else { + $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue(); + } + } + } + return $request_options; + } + +} -- GitLab