[TASK] Add frontend functional tests for site handling 67/57867/4
authorOliver Hader <oliver@typo3.org>
Fri, 10 Aug 2018 21:20:29 +0000 (23:20 +0200)
committerBenni Mack <benni@typo3.org>
Fri, 10 Aug 2018 22:42:13 +0000 (00:42 +0200)
Integrate functional tests for legacy frontend requests
(index.php?id=123) without having any site configuration,
as well as dedicated tests for frontend requests using
the new v9 site handling feature.

Resolves: #85813
Releases: master
Change-Id: I5e08c41010a7482f779e7aa578597cf771429ebd
Reviewed-on: https://review.typo3.org/57867
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Frank Naegler <frank.naegler@typo3.org>
Tested-by: Frank Naegler <frank.naegler@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
typo3/sysext/core/Tests/Functional/Fixtures/Frontend/JsonRenderer.typoscript
typo3/sysext/frontend/Tests/Functional/SiteHandling/AbstractRequestTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/FluidError.html [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/JsonRenderer.typoscript [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PageError.txt [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PhpError.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/scenario.yaml [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/PlainRequestTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/SiteRequestTest.php [new file with mode: 0644]

index 20a954e..49c9486 100644 (file)
@@ -240,6 +240,9 @@ page {
        99999 {
                stdWrap.postUserFunc = TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Renderer->renderValues
                stdWrap.postUserFunc.values {
+                       template.children {
+                               sitetitle.data = tsfe:tmpl|sitetitle
+                       }
                        page.children {
                                uid.data = page:uid
                                pid.data = page:pid
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/AbstractRequestTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/AbstractRequestTest.php
new file mode 100644 (file)
index 0000000..b88861c
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+/**
+ * Abstract test case for frontend requests
+ */
+abstract class AbstractRequestTest extends FunctionalTestCase
+{
+    protected const ENCRYPTION_KEY = '4408d27a916d51e624b69af3554f516dbab61037a9f7b9fd6f81b4d3bedeccb6';
+
+    protected const TYPO3_CONF_VARS = [
+        'SYS' => [
+            'encryptionKey' => self::ENCRYPTION_KEY,
+        ],
+        'FE' => [
+            'cacheHash' => [
+                'requireCacheHashPresenceParameters' => ['testing[value]']
+            ],
+        ]
+    ];
+
+    /**
+     * @var array
+     */
+    protected $coreExtensionsToLoad = ['frontend'];
+
+    /**
+     * Combines string values of multiple array as cross-product into flat items.
+     *
+     * Example:
+     * + meltStrings(['a','b'], ['c','e'], ['f','g'])
+     * + results into ['acf', 'acg', 'aef', 'aeg', 'bcf', 'bcg', 'bef', 'beg']
+     *
+     * @param array $arrays Distinct array that should be melted
+     * @param callable $finalCallback Callback being executed on last multiplier
+     * @param string $prefix Prefix containing concatenated previous values
+     * @return array
+     */
+    protected function meltStrings(array $arrays, callable $finalCallback = null, string $prefix = ''): array
+    {
+        $results = [];
+        $array = array_shift($arrays);
+        foreach ($array as $item) {
+            $resultItem = $prefix . $item;
+            if (count($arrays) > 0) {
+                $results = array_merge(
+                    $results,
+                    $this->meltStrings($arrays, $finalCallback, $resultItem)
+                );
+                continue;
+            }
+            if ($finalCallback !== null) {
+                $resultItem = call_user_func($finalCallback, $resultItem);
+            }
+            $results[] = $resultItem;
+        }
+        return $results;
+    }
+
+    /**
+     * @param array $array
+     * @return array
+     */
+    protected function wrapInArray(array $array): array
+    {
+        return array_map(
+            function ($item) {
+                return [$item];
+            },
+            $array
+        );
+    }
+
+    /**
+     * @param array $array
+     * @return array
+     */
+    protected function keysFromValues(array $array): array
+    {
+        return array_combine($array, $array);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/FluidError.html b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/FluidError.html
new file mode 100644 (file)
index 0000000..af0f60a
--- /dev/null
@@ -0,0 +1,5 @@
+uri: {request.uri}
+message: {message}
+<f:for each="{reasons}" as="reason">
+       reason: {reason}
+</f:for>
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/JsonRenderer.typoscript b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/JsonRenderer.typoscript
new file mode 100644 (file)
index 0000000..ee8fddb
--- /dev/null
@@ -0,0 +1,13 @@
+config {
+  no_cache = 1
+}
+
+page {
+  99999 {
+    stdWrap.postUserFunc.values {
+      getpost.children {
+        testing\.value.data = gp:testing|value
+      }
+    }
+  }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PageError.txt b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PageError.txt
new file mode 100644 (file)
index 0000000..1def3d5
--- /dev/null
@@ -0,0 +1,2 @@
+url: ###CURRENT_URL###
+reason: ###REASON###
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PhpError.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PhpError.php
new file mode 100644 (file)
index 0000000..79a27f7
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface;
+use TYPO3\CMS\Core\Http\JsonResponse;
+
+/**
+ * Test case for frontend requests without having site handling configured
+ */
+class PhpError implements PageErrorHandlerInterface
+{
+    /**
+     * @var int
+     */
+    private $statusCode;
+
+    /**
+     * @var array
+     */
+    private $configuration;
+
+    /**
+     * @param int $statusCode
+     * @param array $configuration
+     */
+    public function __construct(int $statusCode, array $configuration)
+    {
+        $this->statusCode = $statusCode;
+        $this->configuration = $configuration;
+    }
+
+    /**
+     * @param ServerRequestInterface $request
+     * @param string $message
+     * @param array $reasons
+     * @return ResponseInterface
+     */
+    public function handlePageError(
+        ServerRequestInterface $request,
+        string $message,
+        array $reasons = []
+    ): ResponseInterface {
+        $data = [
+            'uri' => (string)$request->getUri(),
+            'message' => $message,
+            'reasons' => $reasons,
+        ];
+        return new JsonResponse($data, $this->statusCode);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/scenario.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/scenario.yaml
new file mode 100644 (file)
index 0000000..2746eea
--- /dev/null
@@ -0,0 +1,77 @@
+__variables:
+  - &pageStandard 0
+  - &pageShortcut 4
+  - &contentText 'text'
+  - &idAcmeRootPage 101
+  - &idAcmeFirstPage 102
+
+entitySettings:
+  '*':
+    nodeColumnName: 'pid'
+    columnNames: {id: 'uid', language: 'sys_language_uid'}
+    defaultValues: {pid: 0}
+  page:
+    isNode: true
+    tableName: 'pages'
+    parentColumnName: 'pid'
+    languageColumnNames: ['l10n_parent', 'l10n_source']
+    columnNames: {type: 'doktype', root: 'is_siteroot'}
+    defaultValues: {hidden: 0, doktype: *pageStandard}
+    valueInstructions:
+      shortcut:
+        first: {shortcut: 0, shortcut_mode: 1}
+  content:
+    tableName: 'tt_content'
+    languageColumnNames: ['l18n_parent', 'l10n_source']
+    columnNames: {title: 'header', type: 'CType'}
+  language:
+    tableName: 'sys_language'
+    columnNames: {code: 'language_isocode'}
+  typoscript:
+    tableName: 'sys_template'
+    valueInstructions:
+      type:
+        site: {root: 1, clear: 1}
+
+entities:
+  language:
+    - self: {id: 1, title: 'French', code: 'fr'}
+    - self: {id: 2, title: 'Franco-Canadian', code: 'fr'}
+  page:
+    - self: {id: *idAcmeRootPage, title: 'ACME Inc', type: *pageShortcut, shortcut: 'first', root: true, alias: 'acme-root'}
+      entities:
+        # @todo Fallback TypoScript, probably remove
+        typoscript:
+          - self:
+              type: site
+              title: 'ACME Inc'
+              sitetitle: 'A Company that Manufactures Everything Inc'
+              config: |
+                page = PAGE
+                page {
+                  10 = TEXT
+                  10.data = page:uid
+                }
+
+      children:
+        - self: {id: *idAcmeFirstPage, title: 'EN: Welcome', alias: 'acme-first'}
+          languageVariants:
+            - self: {title: 'FR: Welcome', language: 1}
+            - self: {title: 'FR-CA: Welcome', language: 2}
+          entities:
+            content:
+              - self: {title: 'EN: Content Element #1', type: *contentText}
+                # @todo does not work due to a bug in DataHandler's remap stack for l10n_source
+                languageVariants:
+                  - self: {title: 'FR: Content Element #1', type: *contentText, language: 1}
+                    languageVariants:
+                      - self: {title: 'FR-CA: Content Element #1', type: *contentText, language: 2}
+              - self: {title: 'EN: Content Element #2', type: *contentText}
+          children:
+            - self: {title: 'EN: Home', type: *pageShortcut, shortcut: *idAcmeRootPage}
+            - self: {title: 'EN: Features'}
+            - self: {title: 'EN: Contact'}
+        - self: {id: 404, title: 'Page not found'}
+          entities:
+            content:
+              - self: {title: 'EN: Page not found', type: *contentText}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/PlainRequestTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/PlainRequestTest.php
new file mode 100644 (file)
index 0000000..24e0ab5
--- /dev/null
@@ -0,0 +1,343 @@
+<?php
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Error\Http\PageNotFoundException;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\ActionService;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataMapFactory;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\ResponseContent;
+
+/**
+ * Test case for frontend requests without having site handling configured
+ */
+class PlainRequestTest extends AbstractRequestTest
+{
+    /**
+     * @var ActionService
+     */
+    private $actionService;
+
+    /**
+     * @var string
+     */
+    private $siteTitle = 'A Company that Manufactures Everything Inc';
+
+    /**
+     * @var InternalRequestContext
+     */
+    private $internalRequestContext;
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        // these settings are forwarded to the frontend sub-request as well
+        $this->internalRequestContext = (new InternalRequestContext())
+            ->withGlobalSettings(['TYPO3_CONF_VARS' => static::TYPO3_CONF_VARS]);
+
+        $this->setUpBackendUserFromFixture(1);
+        $this->actionService = new ActionService();
+
+        $scenarioFile = __DIR__ . '/Fixtures/scenario.yaml';
+        $factory = DataMapFactory::fromYamlFile($scenarioFile);
+        $this->actionService->invoke(
+            $factory->getDataMap(),
+            [],
+            $factory->getSuggestedIds()
+        );
+
+        $this->setUpFrontendRootPage(
+            101,
+            [
+                'typo3/sysext/core/Tests/Functional/Fixtures/Frontend/JsonRenderer.typoscript',
+                'typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/JsonRenderer.typoscript',
+            ],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+    }
+
+    protected function tearDown()
+    {
+        unset(
+            $this->actionService,
+            $this->internalRequestContext
+        );
+        parent::tearDown();
+    }
+
+    /**
+     * @return array
+     */
+    public function requestsAreRedirectedDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'http://localhost/',
+            'https://localhost/',
+            'http://website.local/',
+            'https://website.local/',
+        ];
+
+        $queries = [
+            '?',
+            '?id=101',
+            '?id=acme-root'
+        ];
+
+        return $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider requestsAreRedirectedDataProvider
+     */
+    public function requestsAreRedirected(string $uri)
+    {
+        $expectedStatusCode = 307;
+        $expectedHeaders = ['location' => ['index.php?id=acme-first']];
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        static::assertSame($expectedStatusCode, $response->getStatusCode());
+        static::assertSame($expectedHeaders, $response->getHeaders());
+    }
+
+    /**
+     * @return array
+     */
+    public function pageIsRenderedDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'http://localhost/',
+            'https://localhost/',
+            'http://website.local/',
+            'https://website.local/',
+        ];
+
+        $queries = [
+            '?id=102',
+            '?id=acme-first',
+        ];
+
+        $languageQueries = [
+            '',
+            '&L=0',
+            '&L=1',
+            '&L=2',
+        ];
+
+        return array_map(
+            function (string $uri) {
+                if (strpos($uri, '&L=1') !== false) {
+                    $expectedPageTitle = 'FR: Welcome';
+                } elseif (strpos($uri, '&L=2') !== false) {
+                    $expectedPageTitle = 'FR-CA: Welcome';
+                } else {
+                    $expectedPageTitle = 'EN: Welcome';
+                }
+                return [$uri, $expectedPageTitle];
+            },
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries, $languageQueries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     * @param string $expectedPageTitle
+     *
+     * @test
+     * @dataProvider pageIsRenderedDataProvider
+     */
+    public function pageIsRendered(string $uri, string $expectedPageTitle)
+    {
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $responseStructure = ResponseContent::fromString(
+            (string)$response->getBody()
+        );
+
+        static::assertSame(
+            200,
+            $response->getStatusCode()
+        );
+        static::assertSame(
+            $this->siteTitle,
+            $responseStructure->getScopePath('template/sitetitle')
+        );
+        static::assertSame(
+            $expectedPageTitle,
+            $responseStructure->getScopePath('page/title')
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function pageRenderingStopsWithInvalidCacheHashDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'http://localhost/',
+            'https://localhost/',
+            'http://website.local/',
+            'https://website.local/',
+        ];
+
+        $queries = [
+            '?',
+            '?id=101',
+            '?id=acme-root',
+            '?id=102',
+            '?id=acme-first',
+        ];
+
+        $customQueries = [
+            '&testing[value]=1',
+            '&testing[value]=1&cHash=',
+            '&testing[value]=1&cHash=WRONG',
+        ];
+
+        return $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries, $customQueries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     * @todo In TYPO3 v8 this seemed to be rendered, without throwing that exception
+     */
+    public function pageRequestThrowsExceptionWithInvalidCacheHash(string $uri)
+    {
+        $this->expectExceptionCode(1518472189);
+        $this->expectException(PageNotFoundException::class);
+
+        $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     */
+    public function pageRequestSendsNotFoundResponseWithInvalidCacheHash(string $uri)
+    {
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext->withMergedGlobalSettings([
+                'TYPO3_CONF_VARS' => [
+                    'FE' => [
+                        'pageNotFound_handling' => 'READFILE:typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/PageError.txt',
+                    ]
+                ]
+            ])
+        );
+
+        static::assertSame(
+            404,
+            $response->getStatusCode()
+        );
+        static::assertThat(
+            (string)$response->getBody(),
+            static::logicalOr(
+                static::stringContains('reason: Request parameters could not be validated (&amp;cHash empty)'),
+                static::stringContains('reason: Request parameters could not be validated (&amp;cHash comparison failed)')
+            )
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function pageIsRenderedWithValidCacheHashDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'http://localhost/',
+            'https://localhost/',
+            'http://website.local/',
+            'https://website.local/',
+        ];
+
+        // cHash has been calculated with encryption key set to
+        // '4408d27a916d51e624b69af3554f516dbab61037a9f7b9fd6f81b4d3bedeccb6'
+        $queries = [
+            // @todo Currently fails since cHash is verified after(!) redirect to page 102
+            // '?&cHash=814ea11ad629c7e24cfd031cea2779f4&id=101',
+            // '?&cHash=814ea11ad629c7e24cfd031cea2779f4id=acme-root',
+            '?&cHash=126d2980c12f4759fed1bb7429db2dff&id=102',
+            '?&cHash=126d2980c12f4759fed1bb7429db2dff&id=acme-first',
+        ];
+
+        $customQueries = [
+            '&testing[value]=1',
+        ];
+
+        $dataSet = $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries, $customQueries])
+            )
+        );
+
+        return $dataSet;
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageIsRenderedWithValidCacheHashDataProvider
+     */
+    public function pageIsRenderedWithValidCacheHash($uri)
+    {
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $responseStructure = ResponseContent::fromString(
+            (string)$response->getBody()
+        );
+        static::assertSame(
+            '1',
+            $responseStructure->getScopePath('getpost/testing.value')
+        );
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/SiteRequestTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/SiteRequestTest.php
new file mode 100644 (file)
index 0000000..bc129d7
--- /dev/null
@@ -0,0 +1,672 @@
+<?php
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Configuration\SiteConfiguration;
+use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\PhpError;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\ActionService;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataMapFactory;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\ResponseContent;
+
+/**
+ * Test case for frontend requests having site handling configured
+ */
+class SiteRequestTest extends AbstractRequestTest
+{
+    protected const LANGUAGE_PRESETS = [
+        'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en_US.UTF8', 'iso' => 'en', 'hrefLang' => 'en-US', 'direction' => ''],
+        'FR' => ['id' => 1, 'title' => 'French', 'locale' => 'fr_FR.UTF8', 'iso' => 'fr', 'hrefLang' => 'fr-FR', 'direction' => ''],
+        'FR-CA' => ['id' => 2, 'title' => 'Franco-Canadian', 'locale' => 'fr_CA.UTF8', 'iso' => 'fr', 'hrefLang' => 'fr-CA', 'direction' => ''],
+    ];
+
+    /**
+     * @var array
+     */
+    protected $coreExtensionsToLoad = ['frontend'];
+
+    /**
+     * @var ActionService
+     */
+    private $actionService;
+
+    /**
+     * @var string
+     */
+    private $siteTitle = 'A Company that Manufactures Everything Inc';
+
+    /**
+     * @var InternalRequestContext
+     */
+    private $internalRequestContext;
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        // these settings are forwarded to the frontend sub-request as well
+        $this->internalRequestContext = (new InternalRequestContext())
+            ->withGlobalSettings(['TYPO3_CONF_VARS' => static::TYPO3_CONF_VARS]);
+
+        $this->setUpBackendUserFromFixture(1);
+        $this->actionService = new ActionService();
+
+        $scenarioFile = __DIR__ . '/Fixtures/scenario.yaml';
+        $factory = DataMapFactory::fromYamlFile($scenarioFile);
+        $this->actionService->invoke(
+            $factory->getDataMap(),
+            [],
+            $factory->getSuggestedIds()
+        );
+
+        $this->setUpFrontendRootPage(
+            101,
+            [
+                'typo3/sysext/core/Tests/Functional/Fixtures/Frontend/JsonRenderer.typoscript',
+                'typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/JsonRenderer.typoscript',
+            ],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+    }
+
+    protected function tearDown()
+    {
+        unset(
+            $this->actionService,
+            $this->internalRequestContext
+        );
+        parent::tearDown();
+    }
+
+    /**
+     * @return array
+     */
+    public function requestsAreRedirectedDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'https://localhost/',
+            'https://website.local/',
+        ];
+
+        $queries = [
+            '?',
+            '?id=101',
+            '?id=acme-root'
+        ];
+
+        return $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider requestsAreRedirectedDataProvider
+     */
+    public function requestsAreRedirected(string $uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/')
+        );
+
+        $expectedStatusCode = 307;
+        $expectedHeaders = ['location' => ['/?id=acme-first']];
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        static::assertSame($expectedStatusCode, $response->getStatusCode());
+        static::assertSame($expectedHeaders, $response->getHeaders());
+    }
+
+    /**
+     * @return array
+     */
+    public function pageIsRenderedWithPathsDataProvider(): array
+    {
+        $domainPaths = [
+            // @todo currently base needs to be defined with domain
+            // '/',
+            'https://website.local/',
+        ];
+
+        $languagePaths = [
+            '',
+            'en-en/',
+            'fr-fr/',
+            'fr-ca/',
+        ];
+
+        $queries = [
+            '?id=102',
+            '?id=acme-first',
+        ];
+
+        return array_map(
+            function (string $uri) {
+                if (strpos($uri, '/fr-fr/') !== false) {
+                    $expectedPageTitle = 'FR: Welcome';
+                } elseif (strpos($uri, '/fr-ca/') !== false) {
+                    $expectedPageTitle = 'FR-CA: Welcome';
+                } else {
+                    $expectedPageTitle = 'EN: Welcome';
+                }
+                return [$uri, $expectedPageTitle];
+            },
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $languagePaths, $queries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     * @param string $expectedPageTitle
+     *
+     * @test
+     * @dataProvider pageIsRenderedWithPathsDataProvider
+     */
+    public function pageIsRenderedWithPaths(string $uri, string $expectedPageTitle)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', '/en-en/'),
+                $this->buildLanguageConfiguration('FR', '/fr-fr/', ['EN']),
+                $this->buildLanguageConfiguration('FR-CA', '/fr-ca/', ['FR', 'EN']),
+            ]
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $responseStructure = ResponseContent::fromString(
+            (string)$response->getBody()
+        );
+
+        static::assertSame(
+            200,
+            $response->getStatusCode()
+        );
+        static::assertSame(
+            $this->siteTitle,
+            $responseStructure->getScopePath('template/sitetitle')
+        );
+        static::assertSame(
+            $expectedPageTitle,
+            $responseStructure->getScopePath('page/title')
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function pageIsRenderedWithDomainsDataProvider(): array
+    {
+        $domainPaths = [
+            'https://website.local/',
+            'https://website.us/',
+            'https://website.fr/',
+            'https://website.ca/',
+            'https://website.other/',
+        ];
+
+        $queries = [
+            '?id=102',
+            '?id=acme-first',
+        ];
+
+        return array_map(
+            function (string $uri) {
+                if (strpos($uri, '.fr/') !== false) {
+                    $expectedPageTitle = 'FR: Welcome';
+                } elseif (strpos($uri, '.ca/') !== false) {
+                    $expectedPageTitle = 'FR-CA: Welcome';
+                } else {
+                    $expectedPageTitle = 'EN: Welcome';
+                }
+                return [$uri, $expectedPageTitle];
+            },
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     * @param string $expectedPageTitle
+     *
+     * @test
+     * @dataProvider pageIsRenderedWithDomainsDataProvider
+     */
+    public function pageIsRenderedWithDomains(string $uri, string $expectedPageTitle)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://website.us/'),
+                $this->buildLanguageConfiguration('FR', 'https://website.fr/', ['EN']),
+                $this->buildLanguageConfiguration('FR-CA', 'https://website.ca/', ['FR', 'EN']),
+            ]
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $responseStructure = ResponseContent::fromString(
+            (string)$response->getBody()
+        );
+
+        static::assertSame(
+            200,
+            $response->getStatusCode()
+        );
+        static::assertSame(
+            $this->siteTitle,
+            $responseStructure->getScopePath('template/sitetitle')
+        );
+        static::assertSame(
+            $expectedPageTitle,
+            $responseStructure->getScopePath('page/title')
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function pageRenderingStopsWithInvalidCacheHashDataProvider(): array
+    {
+        $domainPaths = [
+            'https://website.local/',
+        ];
+
+        $queries = [
+            '?',
+            '?id=101',
+            '?id=acme-root',
+            '?id=102',
+            '?id=acme-first',
+        ];
+
+        $customQueries = [
+            '&testing[value]=1',
+            '&testing[value]=1&cHash=',
+            '&testing[value]=1&cHash=WRONG',
+        ];
+
+        return $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries, $customQueries])
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     * @todo In case no error handler is defined, default handler should be used
+     * @see PlainRequestTest::pageRequestSendsNotFoundResponseWithInvalidCacheHash
+     */
+    public function pageRequestThrowsExceptionWithInvalidCacheHashWithoutHavingErrorHandling(string $uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/')
+        );
+
+        $this->expectExceptionCode(1522495914);
+        $this->expectException(\RuntimeException::class);
+
+        $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     */
+    public function pageRequestSendsNotFoundResponseWithInvalidCacheHashWithHavingFluidErrorHandling(string $uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/'),
+            [],
+            $this->buildErrorHandlingConfiguration('Fluid', [404])
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+
+        static::assertSame(
+            404,
+            $response->getStatusCode()
+        );
+        static::assertThat(
+            (string)$response->getBody(),
+            static::logicalOr(
+                static::stringContains('message: Request parameters could not be validated (&amp;cHash empty)'),
+                static::stringContains('message: Request parameters could not be validated (&amp;cHash comparison failed)')
+            )
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     * @todo Response body cannot be asserted since PageContentErrorHandler::handlePageError executes request via HTTP (not internally)
+     */
+    public function pageRequestSendsNotFoundResponseWithInvalidCacheHashWithHavingPageErrorHandling(string $uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/'),
+            [],
+            $this->buildErrorHandlingConfiguration('Page', [404])
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+
+        static::assertSame(
+            404,
+            $response->getStatusCode()
+        );
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageRenderingStopsWithInvalidCacheHashDataProvider
+     */
+    public function pageRequestSendsNotFoundResponseWithInvalidCacheHashWithHavingPhpErrorHandling(string $uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/'),
+            [],
+            $this->buildErrorHandlingConfiguration('PHP', [404])
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $json = json_decode((string)$response->getBody(), true);
+
+        static::assertSame(
+            404,
+            $response->getStatusCode()
+        );
+        static::assertThat(
+            $json['message'] ?? null,
+            static::logicalOr(
+                static::identicalTo('Request parameters could not be validated (&cHash empty)'),
+                static::identicalTo('Request parameters could not be validated (&cHash comparison failed)')
+            )
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function pageIsRenderedWithValidCacheHashDataProvider(): array
+    {
+        $domainPaths = [
+            '/',
+            'https://localhost/',
+            'https://website.local/',
+        ];
+
+        // cHash has been calculated with encryption key set to
+        // '4408d27a916d51e624b69af3554f516dbab61037a9f7b9fd6f81b4d3bedeccb6'
+        $queries = [
+            // @todo Currently fails since cHash is verified after(!) redirect to page 102
+            // '?&cHash=814ea11ad629c7e24cfd031cea2779f4&id=101',
+            // '?&cHash=814ea11ad629c7e24cfd031cea2779f4id=acme-root',
+            '?&cHash=126d2980c12f4759fed1bb7429db2dff&id=102',
+            '?&cHash=126d2980c12f4759fed1bb7429db2dff&id=acme-first',
+        ];
+
+        $customQueries = [
+            '&testing[value]=1',
+        ];
+
+        $dataSet = $this->wrapInArray(
+            $this->keysFromValues(
+                $this->meltStrings([$domainPaths, $queries, $customQueries])
+            )
+        );
+
+        return $dataSet;
+    }
+
+    /**
+     * @param string $uri
+     *
+     * @test
+     * @dataProvider pageIsRenderedWithValidCacheHashDataProvider
+     */
+    public function pageIsRenderedWithValidCacheHash($uri)
+    {
+        $this->writeSiteConfiguration(
+            'website-local',
+            $this->buildSiteConfiguration(101, 'https://website.local/')
+        );
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($uri),
+            $this->internalRequestContext
+        );
+        $responseStructure = ResponseContent::fromString(
+            (string)$response->getBody()
+        );
+        static::assertSame(
+            '1',
+            $responseStructure->getScopePath('getpost/testing.value')
+        );
+    }
+
+    /**
+     * @param string $identifier
+     * @param array $site
+     * @param array $languages
+     * @param array $errorHandling
+     */
+    private function writeSiteConfiguration(
+        string $identifier,
+        array $site = [],
+        array $languages = [],
+        array $errorHandling = []
+    ) {
+        $configuration = [
+            'site' => $site,
+        ];
+        if (!empty($languages)) {
+            $configuration['site']['languages'] = $languages;
+        }
+        if (!empty($errorHandling)) {
+            $configuration['site']['errorHandling'] = $errorHandling;
+        }
+
+        $siteConfiguration = new SiteConfiguration(
+            $this->instancePath . '/typo3conf/sites/'
+        );
+
+        try {
+            $siteConfiguration->write($identifier, $configuration);
+        } catch (\Exception $exception) {
+            $this->markTestSkipped($exception->getMessage());
+        }
+    }
+
+    /**
+     * @param int $rootPageId
+     * @param string $base
+     * @return array
+     */
+    private function buildSiteConfiguration(
+        int $rootPageId,
+        string $base = ''
+    ): array {
+        return [
+            'rootPageId' => $rootPageId,
+            'base' => $base,
+        ];
+    }
+
+    /**
+     * @param string $identifier
+     * @param string $base
+     * @return array
+     */
+    private function buildDefaultLanguageConfiguration(
+        string $identifier,
+        string $base
+    ): array {
+        $configuration = $this->buildLanguageConfiguration($identifier, $base);
+        $configuration['typo3Language'] = 'default';
+        $configuration['flag'] = 'global';
+        unset($configuration['fallbackType']);
+        return $configuration;
+    }
+
+    /**
+     * @param string $identifier
+     * @param string $base
+     * @param array $fallbackIdentifiers
+     * @return array
+     */
+    private function buildLanguageConfiguration(
+        string $identifier,
+        string $base,
+        array $fallbackIdentifiers = []
+    ): array {
+        $preset = $this->resolveLanguagePreset($identifier);
+
+        $configuration = [
+            'languageId' => $preset['id'],
+            'title' => $preset['title'],
+            'navigationTitle' => $preset['title'],
+            'base' => $base,
+            'locale' => $preset['locale'],
+            'iso-639-1' => $preset['iso'],
+            'hreflang' => $preset['hrefLang'],
+            'direction' => $preset['direction'],
+            'typo3Language' => $preset['iso'],
+            'flag' => $preset['iso'],
+            'fallbackType' => 'strict',
+        ];
+
+        if (!empty($fallbackIdentifiers)) {
+            $fallbackIds = array_map(
+                function (string $fallbackIdentifier) {
+                    $preset = $this->resolveLanguagePreset($fallbackIdentifier);
+                    return $preset['id'];
+                },
+                $fallbackIdentifiers
+            );
+            $configuration['fallbackType'] = 'fallback';
+            $configuration['fallbackType'] = implode(',', $fallbackIds);
+        }
+
+        return $configuration;
+    }
+
+    /**
+     * @param string $handler
+     * @param array $codes
+     * @return array
+     */
+    private function buildErrorHandlingConfiguration(
+        string $handler,
+        array $codes
+    ): array {
+        if ($handler === 'Page') {
+            $baseConfiguration = [
+                'errorContentSource' => '404',
+            ];
+        } elseif ($handler === 'Fluid') {
+            $baseConfiguration = [
+                'errorFluidTemplate' => 'typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/FluidError.html',
+                'errorFluidTemplatesRootPath' => '',
+                'errorFluidLayoutsRootPath' => '',
+                'errorFluidPartialsRootPath' => '',
+            ];
+        } elseif ($handler === 'PHP') {
+            $baseConfiguration = [
+                'errorPhpClassFQCN' => PhpError::class,
+            ];
+        } else {
+            throw new \LogicException(
+                sprintf('Invalid handler "%s"', $handler),
+                1533894782
+            );
+        }
+
+        $baseConfiguration['errorHandler'] = $handler;
+
+        return array_map(
+            function (int $code) use ($baseConfiguration) {
+                $baseConfiguration['errorCode'] = $code;
+                return $baseConfiguration;
+            },
+            $codes
+        );
+    }
+
+    /**
+     * @param string $identifier
+     * @return mixed
+     */
+    private function resolveLanguagePreset(string $identifier)
+    {
+        if (!isset(static::LANGUAGE_PRESETS[$identifier])) {
+            throw new \LogicException(
+                sprintf('Undefined preset identifier "%s"', $identifier),
+                1533893665
+            );
+        }
+        return static::LANGUAGE_PRESETS[$identifier];
+    }
+}