From 41dfad388cbdb43664003f420b516b07a70e713e Mon Sep 17 00:00:00 2001 From: Lee Rowlands <lee.rowlands@previousnext.com.au> Date: Sat, 12 May 2018 08:20:15 +1000 Subject: [PATCH] Issue #2854543 by Wim Leers, polynya, dawehner, mpdonadio, gabesullice, larowlan, klausi: NegotiationMiddleware calls $request->setRequestFormat('html') when there is no _format request parameter, but shouldn't --- .../Core/Routing/RequestFormatRouteFilter.php | 37 +++++++--- .../StackMiddleware/NegotiationMiddleware.php | 8 ++- .../modules/rest_test/rest_test.services.yml | 4 ++ .../EntityResource/EntityResourceTestBase.php | 68 +++++++++++++------ .../default_format_test.info.yml | 6 ++ .../default_format_test.routing.yml | 28 ++++++++ .../src/DefaultFormatTestController.php | 15 ++++ .../Routing/DefaultFormatTest.php | 32 +++++++++ .../NegotiationMiddlewareTest.php | 10 +-- 9 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 core/modules/system/tests/modules/default_format_test/default_format_test.info.yml create mode 100644 core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml create mode 100644 core/modules/system/tests/modules/default_format_test/src/DefaultFormatTestController.php create mode 100644 core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php diff --git a/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php index dbfb801b7856..7448e6f1efc6 100644 --- a/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php @@ -4,6 +4,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; +use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; /** @@ -17,7 +18,14 @@ class RequestFormatRouteFilter implements FilterInterface { public function filter(RouteCollection $collection, Request $request) { // Determine the request format. $default_format = static::getDefaultFormat($collection); - $format = $request->getRequestFormat($default_format); + // If the request does not specify a format then use the default. + if (is_null($request->getRequestFormat(NULL))) { + $format = $default_format; + $request->setRequestFormat($default_format); + } + else { + $format = $request->getRequestFormat($default_format); + } $routes_with_requirement = []; $routes_without_requirement = []; @@ -60,7 +68,9 @@ public function filter(RouteCollection $collection, Request $request) { * * By default, use 'html' as the default format. But when there's only a * single route match, and that route specifies a '_format' requirement - * listing a single format, then use that as the default format. + * listing a single format, then use that as the default format. Also, if + * there are multiple routes which all require the same single format then + * use it. * * @param \Symfony\Component\Routing\RouteCollection $collection * The route collection to filter. @@ -69,15 +79,20 @@ public function filter(RouteCollection $collection, Request $request) { * The default format. */ protected static function getDefaultFormat(RouteCollection $collection) { - $default_format = 'html'; - if ($collection->count() === 1) { - $only_route = $collection->getIterator()->current(); - $required_format = $only_route->getRequirement('_format'); - if (strpos($required_format, '|') === FALSE) { - $default_format = $required_format; - } - } - return $default_format; + // Get the set of formats across all routes in the collection. + $all_formats = array_reduce($collection->all(), function (array $carry, Route $route) { + // Routes without a '_format' requirement are assumed to require HTML. + $route_formats = !$route->hasRequirement('_format') + ? ['html'] + : explode('|', $route->getRequirement('_format')); + return array_merge($carry,$route_formats); + }, []); + $formats = array_unique(array_filter($all_formats)); + + // The default format is 'html' unless ALL routes require the same format. + return count($formats) === 1 + ? reset($formats) + : 'html'; } } diff --git a/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php b/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php index e3864610f4f9..f246cce4624e 100644 --- a/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php +++ b/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php @@ -46,7 +46,9 @@ public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = } // Determine the request format using the negotiator. - $request->setRequestFormat($this->getContentType($request)); + if ($requested_format = $this->getContentType($request)) { + $request->setRequestFormat($requested_format); + } return $this->app->handle($request, $type, $catch); } @@ -88,8 +90,8 @@ protected function getContentType(Request $request) { return $request->query->get('_format'); } - // Do HTML last so that it always wins. - return 'html'; + // No format was specified in the request. + return NULL; } } diff --git a/core/modules/rest/tests/modules/rest_test/rest_test.services.yml b/core/modules/rest/tests/modules/rest_test/rest_test.services.yml index d316cf6072b8..85b418a6c68a 100644 --- a/core/modules/rest/tests/modules/rest_test/rest_test.services.yml +++ b/core/modules/rest/tests/modules/rest_test/rest_test.services.yml @@ -12,3 +12,7 @@ services: public: false tags: - { name: page_cache_request_policy } + rest_test.encoder.foobar: + class: Drupal\serialization\Encoder\JsonEncoder + tags: + - { name: encoder, format: foobar } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 2ab45273d505..b5d7fbb7d45d 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -156,12 +156,22 @@ abstract class EntityResourceTestBase extends ResourceTestBase { /** * Provides an entity resource. + * + * @param bool $single_format + * Provisions a single-format entity REST resource. Defaults to FALSE. */ - protected function provisionEntityResource() { + protected function provisionEntityResource($single_format = FALSE) { + if ($existing = $this->resourceConfigStorage->load(static::$resourceConfigId)) { + $existing->delete(); + } + + $format = $single_format + ? [static::$format] + : [static::$format, 'foobar']; // It's possible to not have any authentication providers enabled, when // testing public (anonymous) usage of a REST resource. $auth = isset(static::$auth) ? [static::$auth] : []; - $this->provisionResource([static::$format], $auth); + $this->provisionResource($format, $auth); } /** @@ -434,20 +444,6 @@ public function testGet() { } $this->provisionEntityResource(); - // Simulate the developer again forgetting the ?_format query string. - $url->setOption('query', []); - - // DX: 406 when ?_format is missing, except when requesting a canonical HTML - // route. - $response = $this->request('GET', $url, $request_options); - if ($has_canonical_url && (!static::$auth || static::$auth === 'cookie')) { - $this->assertSame(403, $response->getStatusCode()); - } - else { - $this->assert406Response($response); - } - - $url->setOption('query', ['_format' => static::$format]); // DX: forgetting authentication: authentication provider-specific error // response. @@ -472,10 +468,44 @@ public function testGet() { unset($request_options[RequestOptions::HEADERS]['REST-test-auth-global']); $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('GET')); - // DX: 403 when unauthorized. - $response = $this->request('GET', $url, $request_options); + // First: single format. Drupal will automatically pick the only format. + $this->provisionEntityResource(TRUE); $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); - $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS'); + // DX: 403 because unauthorized single-format route, ?_format is omittable. + $url->setOption('query', []); + $response = $this->request('GET', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + else { + $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS'); + } + $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache')); + // DX: 403 because unauthorized. + $url->setOption('query', ['_format' => static::$format]); + $response = $this->request('GET', $url, $request_options); + $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', $has_canonical_url ? 'MISS' : 'HIT'); + + // Then, what we'll use for the remainder of the test: multiple formats. + $this->provisionEntityResource(); + // DX: 406 because despite unauthorized, ?_format is not omittable. + $url->setOption('query', []); + $response = $this->request('GET', $url, $request_options); + if ($has_canonical_url) { + $this->assertSame(403, $response->getStatusCode()); + $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Dynamic-Cache')); + } + else { + $this->assertSame(406, $response->getStatusCode()); + $this->assertSame(['UNCACHEABLE'], $response->getHeader('X-Drupal-Dynamic-Cache')); + } + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache')); + // DX: 403 because unauthorized. + $url->setOption('query', ['_format' => static::$format]); + $response = $this->request('GET', $url, $request_options); + $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'HIT'); $this->assertArrayNotHasKey('Link', $response->getHeaders()); $this->setUpAuthorization('GET'); diff --git a/core/modules/system/tests/modules/default_format_test/default_format_test.info.yml b/core/modules/system/tests/modules/default_format_test/default_format_test.info.yml new file mode 100644 index 000000000000..0a02a0688a13 --- /dev/null +++ b/core/modules/system/tests/modules/default_format_test/default_format_test.info.yml @@ -0,0 +1,6 @@ +name: 'Default format test' +type: module +description: 'Support module for testing default route format.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml b/core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml new file mode 100644 index 000000000000..368dcb1946ce --- /dev/null +++ b/core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml @@ -0,0 +1,28 @@ +default_format_test.machine: + path: '/default_format_test/machine' + defaults: + # Same controller + method! + _controller: '\Drupal\default_format_test\DefaultFormatTestController::content' + requirements: + _access: 'TRUE' + _format: 'json' + +default_format_test.human: + path: '/default_format_test/human' + defaults: + # Same controller + method! + _controller: '\Drupal\default_format_test\DefaultFormatTestController::content' + requirements: + _access: 'TRUE' + _format: 'html' + +# Route definition identical to default_format_test.machine, only different name. +# @see \Drupal\FunctionalTests\Routing\DefaultFormatTest::testMultiple +default_format_test.machine.alias: + path: '/default_format_test/machine' + defaults: + # Same controller + method! + _controller: '\Drupal\default_format_test\DefaultFormatTestController::content' + requirements: + _access: 'TRUE' + _format: 'json' diff --git a/core/modules/system/tests/modules/default_format_test/src/DefaultFormatTestController.php b/core/modules/system/tests/modules/default_format_test/src/DefaultFormatTestController.php new file mode 100644 index 000000000000..6e52eea342bb --- /dev/null +++ b/core/modules/system/tests/modules/default_format_test/src/DefaultFormatTestController.php @@ -0,0 +1,15 @@ +<?php + +namespace Drupal\default_format_test; + +use Drupal\Core\Cache\CacheableResponse; +use Symfony\Component\HttpFoundation\Request; + +class DefaultFormatTestController { + + public function content(Request $request) { + $format = $request->getRequestFormat(); + return new CacheableResponse('format:' . $format, 200, ['Content-Type' => $request->getMimeType($format)]); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php b/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php new file mode 100644 index 000000000000..bf695d658bae --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\FunctionalTests\Routing; + +use Drupal\Tests\BrowserTestBase; + +/** + * @group routing + */ +class DefaultFormatTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['system', 'default_format_test']; + + public function testFoo() { + $this->drupalGet('/default_format_test/human'); + $this->assertSame('format:html', $this->getSession()->getPage()->getContent()); + $this->assertSame('MISS', $this->drupalGetHeader('X-Drupal-Cache')); + + $this->drupalGet('/default_format_test/machine'); + $this->assertSame('format:json', $this->getSession()->getPage()->getContent()); + $this->assertSame('MISS', $this->drupalGetHeader('X-Drupal-Cache')); + } + + public function testMultipleRoutesWithSameSingleFormat() { + $this->drupalGet('/default_format_test/machine'); + $this->assertSame('format:json', $this->getSession()->getPage()->getContent()); + } + +} diff --git a/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php b/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php index 10d2df8b8ba2..7b5217d4c5ee 100644 --- a/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php +++ b/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php @@ -68,10 +68,10 @@ public function testFormatViaQueryParameter() { * * @covers ::getContentType */ - public function testUnknowContentTypeReturnsHtmlByDefault() { + public function testUnknowContentTypeReturnsNull() { $request = new Request(); - $this->assertSame('html', $this->contentNegotiation->getContentType($request)); + $this->assertNull($this->contentNegotiation->getContentType($request)); } /** @@ -83,7 +83,7 @@ public function testUnknowContentTypeButAjaxRequest() { $request = new Request(); $request->headers->set('X-Requested-With', 'XMLHttpRequest'); - $this->assertSame('html', $this->contentNegotiation->getContentType($request)); + $this->assertNull($this->contentNegotiation->getContentType($request)); } /** @@ -98,7 +98,7 @@ public function testHandle() { $request->setFormat()->shouldNotBeCalled(); // Request format will be set with default format. - $request->setRequestFormat('html')->shouldBeCalled(); + $request->setRequestFormat()->shouldNotBeCalled(); // Some getContentType calls we don't really care about but have to mock. $request_data = $this->prophesize(ParameterBag::class); @@ -127,7 +127,7 @@ public function testSetFormat() { $request->setFormat('david', 'geeky/david')->shouldBeCalled(); // Some calls we don't care about. - $request->setRequestFormat('html')->shouldBeCalled(); + $request->setRequestFormat()->shouldNotBeCalled(); $request_data = $this->prophesize(ParameterBag::class); $request_data->get('ajax_iframe_upload', FALSE)->shouldBeCalled(); $request_mock = $request->reveal(); -- GitLab