Commit 73ed5173 authored by waldhacker's avatar waldhacker Committed by Stefan Bürk
Browse files

[TASK] Add functional tests to test the caching behavior of ext:form

In preparation for patchset
https://review.typo3.org/c/Packages/TYPO3.CMS/+/70460/,
functional tests are introduced to test the caching behavior
of EXT:form.

Resolves: #97049
Related: #93887
Releases: main
Change-Id: Ief1c8d90371d6003512f88a064c2d82e51502590
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/73722


Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Oliver Klee's avatarOliver Klee <typo3-coding@oliverklee.de>
Tested-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Stefan Bürk's avatarStefan Bürk <stefan@buerk.tech>
parent 6fdb5153
......@@ -295,6 +295,7 @@
"typo3/sysext/extbase/Tests/Functional/Fixtures/",
"typo3/sysext/extbase/Tests/Functional/Mvc/Controller/Fixture/",
"typo3/sysext/fluid/Tests/Functional/Fixtures/Extensions/fluid_test/Classes/",
"typo3/sysext/form/Tests/Functional/RequestHandling/Fixtures/Extensions/form_caching_tests/Classes/",
"typo3/sysext/frontend/Tests/Functional/Fixtures/Extensions/test_request_mirror/Classes/"
]
}
......
config {
no_cache = 0
debug = 0
admPanel = 0
disableAllHeaderCode = 1
sendCacheHeaders = 0
}
page = PAGE
page {
typeNum = 0
10 < styles.content.get
20 < styles.content.get
20 {
select.where = {#colPos}=1
slide = -1
}
}
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Form\Tests\Functional\Framework\FormHandling;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
/**
* Used to hold and prepare data for form testing purpose.
* @see \TYPO3\CMS\Form\Tests\Functional\RequestHandling\RequestHandlingTest
*/
final class FormData
{
private array $with = [];
private array $withNoPrefix = [];
private array $without = [];
private array $withoutNoPrefix = [];
private bool $withChash = true;
public function __construct(private array $formData)
{
}
public function with(string $identifier, string $value): FormData
{
$this->with[$identifier] = $value;
return $this;
}
public function withNoPrefix(string $identifier, string $value): FormData
{
$this->withNoPrefix[$identifier] = $value;
return $this;
}
public function without(string $identifier): FormData
{
$this->without[$identifier] = $identifier;
return $this;
}
public function withoutNoPrefix(string $identifier): FormData
{
$this->withoutNoPrefix[$identifier] = $identifier;
return $this;
}
public function withChash(bool $withChash): FormData
{
$this->withChash = $withChash;
return $this;
}
public function toPostRequest(InternalRequest $request): InternalRequest
{
$parsedBody = [];
$postStructure = $this->getPostStructure();
parse_str($postStructure, $parsedBody);
$request->getBody()->write($postStructure);
return $request
->withMethod('POST')
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withQueryParameters($this->getQueryStructure())
->withParsedBody($parsedBody);
}
public function toGetRequest(InternalRequest $request, bool $withPostData = true): InternalRequest
{
$postStructure = [];
if ($withPostData) {
foreach (explode('&', urldecode($this->getPostStructure())) as $queryPart) {
[$key, $value] = explode('=', $queryPart, 2);
$postStructure[$key] = $value;
}
}
$queryParameters = array_replace_recursive(
$this->getQueryStructure(),
$postStructure
);
$request->getBody()->write($this->getPostStructure());
return $request
->withMethod('GET')
->withQueryParameters($queryParameters);
}
public function toArray(): array
{
return $this->formData;
}
public function getFormMarkup(): string
{
return $this->formData['DOMDocument']->saveHTML();
}
public function getHoneypotId(): ?string
{
return array_values(
array_filter(
$this->formData['elementData'],
fn ($elementData) => $elementData['__isHoneypot']
)
)[0]['autocomplete'] ?? null;
}
public function getSessionId(): ?string
{
return array_values(
array_filter(
$this->formData['elementData'],
fn ($elementData) => str_ends_with($elementData['name'], '[__session]')
)
)[0]['value'] ?? null;
}
private function getQueryStructure(): array
{
$queryStructure = [];
$actionQueryData = $this->formData['actionQueryData'];
if ($this->withChash === false) {
unset($actionQueryData['cHash']);
}
$actionQuery = http_build_query($actionQueryData);
foreach (explode('&', urldecode($actionQuery)) as $queryPart) {
[$key, $value] = explode('=', $queryPart, 2);
$queryStructure[$key] = $value;
}
return $queryStructure;
}
private function getPostStructure(): string
{
$dataPrefix = '';
$postStructure = [];
foreach ($this->formData['elementData'] as $elementData) {
$nameStruct = [];
parse_str(sprintf('%s=%s', $elementData['name'], $elementData['value'] ?? ''), $nameStruct);
$postStructure = array_replace_recursive($postStructure, $nameStruct);
if (str_ends_with($elementData['name'], '[__state]')) {
$prefix = key(ArrayUtility::flatten($nameStruct));
$prefixItems = explode('.', $prefix);
array_pop($prefixItems);
$dataPrefix = implode('.', $prefixItems) . '.';
}
}
foreach ($this->with as $identifier => $value) {
$postStructure = ArrayUtility::setValueByPath($postStructure, $dataPrefix . $identifier, $value, '.');
}
foreach ($this->without as $identifier) {
$postStructure = ArrayUtility::removeByPath($postStructure, $dataPrefix . $identifier, '.');
}
$postStructure = array_replace_recursive(
$postStructure,
$this->withNoPrefix
);
foreach ($this->withoutNoPrefix as $identifier) {
unset($postStructure[$identifier]);
}
return http_build_query($postStructure);
}
}
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Form\Tests\Functional\Framework\FormHandling;
/**
* Used to extract data from retrieved html markup for form testing purpose.
* @see \TYPO3\CMS\Form\Tests\Functional\RequestHandling\RequestHandlingTest
*/
final class FormDataFactory
{
public function fromHtmlMarkupAndXpath(string $html, string $query = '//form'): FormData
{
return new FormData(
$this->buildFormData(
$this->extractFormFragment($html, $query)
)
);
}
private function buildFormData(\DOMDocument $document): array
{
$data = [
'actionQueryData' => [],
'actionUrl' => '',
'elementData' => [],
'DOMDocument' => $document,
];
foreach ($document->getElementsByTagName('form') as $node) {
$action = $node->getAttribute('action');
$actionQuery = parse_url($action, PHP_URL_QUERY);
$queryArray = [];
parse_str($actionQuery, $queryArray);
$data['actionQueryData'] = $queryArray;
[$actionUrl, ] = explode('?', $action);
$data['actionUrl'] = $actionUrl;
break;
}
$xpath = new \DomXPath($document);
$nodesWithName = $xpath->query('//*[@name]');
foreach ($nodesWithName as $node) {
$name = $node->getAttribute('name');
foreach ($node->attributes ?? [] as $attribute) {
$data['elementData'][$name][$attribute->nodeName] = $attribute->nodeValue;
}
$data['elementData'][$name]['__isHoneypot'] = $this->isHoneypot($node);
}
return $data;
}
private function extractFormFragment(string $html, string $query): \DOMDocument
{
$document = new \DOMDocument();
$document->loadHTML($html, LIBXML_NOERROR);
$xpath = new \DomXPath($document);
$fragment = new \DOMDocument();
foreach ($xpath->query($query) as $node) {
$fragment->appendChild($fragment->importNode($node, true));
}
return $fragment;
}
private function isHoneypot(\DOMElement $node): bool
{
if (!$node->hasAttribute('id')) {
return false;
}
if (!$node->hasAttribute('autocomplete')) {
return false;
}
return str_ends_with($node->getAttribute('id'), $node->getAttribute('autocomplete'));
}
}
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\Form\Tests\Functional\RequestHandling;
use Symfony\Component\Mailer\SentMessage;
use TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend;
use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory;
use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
abstract class AbstractRequestHandlingTest extends FunctionalTestCase
{
use SiteBasedTestTrait;
protected const ROOT_PAGE_BASE_URI = 'http://localhost';
protected const LANGUAGE_PRESETS = [
'EN' => ['id' => 0, 'title' => 'English', 'locale' => 'en_GB.UTF8', 'iso' => 'en', 'hrefLang' => 'en-GB', 'direction' => ''],
];
protected const MAIL_SPOOL_FOLDER = 'typo3temp/var/transient/spool/';
protected array $coreExtensionsToLoad = ['form', 'fluid_styled_content'];
protected array $testExtensionsToLoad = [
'typo3/sysext/form/Tests/Functional/RequestHandling/Fixtures/Extensions/form_caching_tests',
];
protected array $configurationToUseInTestInstance = [
'MAIL' => [
'defaultMailFromAddress' => 'hello@typo3.org',
'defaultMailFromName' => 'TYPO3',
'transport' => 'mbox',
'transport_spool_type' => 'file',
'transport_spool_filepath' => self::MAIL_SPOOL_FOLDER,
],
'SYS' => [
'caching' => [
'cacheConfigurations' => [
'hash' => [
'backend' => Typo3DatabaseBackend::class,
'frontend' => VariableFrontend::class,
],
'pages' => [
'backend' => Typo3DatabaseBackend::class,
'frontend' => VariableFrontend::class,
],
'pagesection' => [
'backend' => Typo3DatabaseBackend::class,
'frontend' => VariableFrontend::class,
],
'rootline' => [
'backend' => Typo3DatabaseBackend::class,
'frontend' => VariableFrontend::class,
],
],
],
'encryptionKey' => '4408d27a916d51e624b69af3554f516dbab61037a9f7b9fd6f81b4d3bedeccb6',
],
];
protected string $databaseScenarioFile = '';
protected function setUp(): void
{
parent::setUp();
$this->writeSiteConfiguration(
'site1',
$this->buildSiteConfiguration(1000, static::ROOT_PAGE_BASE_URI . '/'),
[
$this->buildDefaultLanguageConfiguration('EN', '/'),
]
);
$this->withDatabaseSnapshot(function () {
$this->setUpDatabase();
});
}
protected function tearDown(): void
{
$this->purgeMailSpool();
parent::tearDown();
}
private function setUpDatabase(): void
{
$backendUser = $this->setUpBackendUserFromFixture(1);
Bootstrap::initializeLanguageObject();
$factory = DataHandlerFactory::fromYamlFile($this->databaseScenarioFile);
$writer = DataHandlerWriter::withBackendUser($backendUser);
$writer->invokeFactory($factory);
static::failIfArrayIsNotEmpty(
$writer->getErrors()
);
}
protected function getMailSpoolMessages(): array
{
$messages = [];
foreach (array_filter(glob($this->instancePath . '/' . self::MAIL_SPOOL_FOLDER . '*'), 'is_file') as $path) {
$serializedMessage = file_get_contents($path);
$sentMessage = unserialize($serializedMessage);
if (!$sentMessage instanceof SentMessage) {
continue;
}
$fluidEmail = $sentMessage->getOriginalMessage();
if (!$fluidEmail instanceof FluidEmail) {
continue;
}
$parsedHeaders = $this->parseRawHeaders($sentMessage->toString());
$item = [
'plaintext' => $fluidEmail->getTextBody(),
'html' => $fluidEmail->getHtmlBody(),
'subject' => $fluidEmail->getSubject(),
'date' => $fluidEmail->getDate() ?? $parsedHeaders['Date'] ?? null,
'to' => $fluidEmail->getTo(),
];
if (is_string($item['date'])) {
// @todo `@timezone` is not handled here - probably tests don't need date at all
$item['date'] = new \DateTimeImmutable($item['date']);
}
$messages[] = $item;
}
return $messages;
}
/**
* @param string $rawMessage
* @return array<string, string>
*/
protected function parseRawHeaders(string $rawMessage): array
{
$rawParts = explode("\r\n\r\n", $rawMessage, 2);
$rawLines = explode("\r\n", $rawParts[0]);
$rawHeaders = array_map(
fn (string $rawLine) => array_map(
'trim',
explode(':', $rawLine, 2)
),
$rawLines
);
return array_combine(
array_column($rawHeaders, 0),
array_column($rawHeaders, 1)
);
}
protected function purgeMailSpool(): void
{
foreach (glob($this->instancePath . '/' . self::MAIL_SPOOL_FOLDER . '*') as $path) {
unlink($path);
}
}
}
<?php
declare(strict_types=1);
/*
* 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!
*/
namespace TYPO3\CMS\FormCachingTests\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class FormCachingTestsController extends ActionController
{
public function someRenderAction(): ResponseInterface
{
$this->view->assign('formIdentifier', $this->request->getPluginName() . '-' . $this->configurationManager->getContentObject()->data['uid']);
$this->view->assign('pluginName', $this->request->getPluginName());
return $this->htmlResponse();
}
public function somePerformAction(): ResponseInterface
{
$this->view->assign('formIdentifier', $this->request->getPluginName() . '-' . $this->configurationManager->getContentObject()->data['uid']);
$this->view->assign('pluginName', $this->request->getPluginName());
return $this->htmlResponse();
}
}
services:
_defaults:
autowire: true
autoconfigure: true
public: false
TYPO3\CMS\FormCachingTests\:
resource: '../Classes/*'
<?php
defined('TYPO3') or die();
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addStaticFile('form_caching_tests', 'Configuration/TypoScript', 'extbase form tests');
<?php
defined('TYPO3') or die();
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin('FormCachingTests', 'AllActionsCached', 'form caching test - all actions cached', 'information-typo3-version');
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin('FormCachingTests', 'RenderActionIsCached', 'form caching test - render action cached', 'information-typo3-version');
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin('FormCachingTests', 'AllActionsUncached', 'form caching test - all actions uncached', 'information-typo3-version');
plugin.tx_formcachingtests {
view {
layoutRootPaths {
0 = EXT:form_caching_tests/Resources/Private/Layouts/
}
templateRootPaths {
0 = EXT:form_caching_tests/Resources/Private/Templates/
}
partialRootPaths {
0 = EXT:form_caching_tests/Resources/Private/Partials/
}
}
}
page = PAGE
page {