Harden KantSites after the security review

- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-12 14:38:25 +02:00
1 parent 14e7a3b310
commit ad7c333215
43 files changed
+810 -53

No files matched your search

+3 -1
View File
@@ -272,7 +272,9 @@ final class ArchiveUpdateTest extends TestCase
// Beiwerk, das eine laufende Installation nicht liest.
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
// Betreiber installiertes bleibt stehen (ADR 102).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
// Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören.
// Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess'];
foreach ($liste($mit[1]) as $eintrag) {
self::assertContains(
$eintrag,
+1 -1
View File
@@ -158,7 +158,7 @@ final class BacklogTest extends TestCase
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
$issued = time() - 10;
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
self::assertSame(422, $antwort->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
+48
View File
@@ -3098,6 +3098,26 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
}
public function testALinkedSectionIsRenderedByItsOwnSchemaVersion(): void
{
// Eine Sektion mit altem Schema-Stand: ihr Text lief nicht durch den Filter für
// formatierten Text. In einer neueren Seite darf er deshalb nicht roh erscheinen (ADR 116).
$this->partials->create('section', 'alt', 'Alte Sektion', [
'schemaVersion' => 11, 'title' => 'Alt', 'description' => 'Alte Sektion.',
'blocks' => [['id' => 'alt-section', 'type' => 'section', 'children' => [
['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => '<meta http-equiv="refresh" content="0;url=https://fremd.example"><a href="https://fremd.example">Hier</a>']],
]]],
]);
$document = DemoContent::starter('Neu');
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'alt']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = (string) $this->request('/')->getContent();
self::assertStringNotContainsString('<meta http-equiv="refresh"', $html);
self::assertStringContainsString('&lt;meta http-equiv=', $html, 'Der Text erscheint als Text.');
}
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
{
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
@@ -3470,6 +3490,24 @@ display: flex', '#b1{display:flex;color:red}'];
$this->pages->saveDraft('page-1', $document, 0);
}
public function testOwnCssStaysInsideItsBlock(): void
{
$code = new CustomCode();
// Geschwister des Blocks selbst liegen außerhalb - die ganze Seite wäre umgestaltbar (ADR 116).
foreach (['& ~ *{display:none}', '&+p{color:red}', '&[a="x]{color:red}', 'color:red /*', 'width:10px*/'] as $css) {
try {
$code->checkCss($css);
self::fail('Angenommen: '.$css);
} catch (InvalidDocument) {
self::addToAssertionCount(1);
}
// Was schon gespeichert ist und heute nicht mehr gilt, fällt still weg statt die Seite zu brechen.
self::assertSame('', $code->compileCss('b1', $css));
}
// Innerhalb des Blocks bleiben ~ und + erlaubt.
self::assertSame('#b1 .a~.b{color:red}#b1 h3+p{margin:0}', $code->compileCss('b1', '& .a~.b{color:red}& h3+p{margin:0}'));
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
@@ -4614,6 +4652,16 @@ Achern']);
}
}
public function testTheConsentFormRedirectsOnlyWithinThisSite(): void
{
$this->askConsent();
// Browser entfernen Tabulator und Zeilenumbruch: aus /<Tab>/fremd würde //fremd (ADR 116).
foreach (["/\t/fremd.example", "/\n/fremd.example", '//fremd.example', '/\fremd.example', 'https://fremd.example', '/ /fremd.example'] as $back) {
self::assertSame('/', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $back])->headers->get('Location'), json_encode($back));
}
self::assertSame('/kontakt?a=1#x', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/kontakt?a=1#x'])->headers->get('Location'));
}
public function testTheConsentScriptSavesWithoutARedirect(): void
{
$this->askConsent();
+91 -3
View File
@@ -356,6 +356,54 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testEditorsChangeLivePagesOnlyThroughAPublisher(): void
{
// Eine veröffentlichte Unterseite und ein reiner Entwurf (ADR 116).
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$live = $pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
$seite = $pages->findForEditor($live);
$pages->publish($live, $seite['draft_revision_id'], $seite['version']);
$entwurf = $pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
$version = static fn (string $id): int => (int) $pages->findForEditor($id)['version'];
$client = $this->login('editor');
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
foreach (['move' => ['slug' => 'umbenannt'], 'trash' => [], 'bereiche' => ['headerArea' => '', 'footerArea' => 'none'], 'meta' => ['robots' => 'noindex']] as $aktion => $felder) {
$client->request('POST', '/admin/pages/'.$live.'/'.$aktion, ['_csrf_token' => $token, 'expectedVersion' => $version($live), ...$felder]);
self::assertResponseRedirects();
}
$nachher = $this->db->fetchAssociative('SELECT path, deleted_at, footer_area, robots FROM page WHERE id = ?', [$live]);
self::assertSame('leistungen', $nachher['path'], 'Ein Redakteur verschiebt keine veröffentlichte Seite.');
self::assertNull($nachher['deleted_at'], 'Ein Redakteur legt keine veröffentlichte Seite in den Papierkorb.');
self::assertNull($nachher['footer_area']);
self::assertNotSame('noindex', $nachher['robots']);
// Einen Entwurf, der nie online war, bearbeitet er weiter frei.
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
// Wer veröffentlichen darf, darf auch das.
$client = $this->login('publisher', $client);
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
}
public function testAScheduledPublicationPublishesTheRevisionThatWasScheduled(): void
{
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$id = $pages->createPage('termin', null, 'page', DemoContent::starter('Geprüfter Stand'));
$geprueft = $pages->findForEditor($id)['draft_revision_id'];
$pages->schedule($id, '2026-01-01 08:00', (int) $pages->findForEditor($id)['version']);
// Nach dem Planen speichert jemand einen anderen Entwurf.
$pages->saveDraft($id, DemoContent::starter('Später gespeichert'), (int) $pages->findForEditor($id)['version']);
self::assertNotSame($geprueft, $pages->findForEditor($id)['draft_revision_id']);
self::assertSame(['termin'], $pages->publishDue());
self::assertSame($geprueft, $this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]), 'Online geht der geplante Stand, nicht der spätere Entwurf.');
self::assertNull($this->db->fetchOne('SELECT publish_revision_id FROM page WHERE id = ?', [$id]));
}
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
{
$client = $this->login('admin');
@@ -499,7 +547,7 @@ final class EditorSecurityTest extends WebTestCase
{
return [
'page' => 'home', 'block' => 'formblock', 'website' => '',
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
'issued' => $issued, 'issuedSignature' => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret'),
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
];
@@ -611,7 +659,7 @@ final class EditorSecurityTest extends WebTestCase
*/
public static function droppedSubmissions(): iterable
{
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
$sign = static fn (string $issued): string => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret');
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
@@ -650,7 +698,47 @@ final class EditorSecurityTest extends WebTestCase
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
self::assertResponseStatusCodeSame(303);
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
self::assertTrue($forms->rateLimited(FormRepository::senderKey('127.0.0.1', 'isolated-test-secret')));
}
public function testSendersOfOneIpv6NetworkCountTogether(): void
{
// Ein Anschluss hat ein ganzes /64-Netz; jede Adresse daraus ist derselbe Absender (ADR 116).
$a = FormRepository::senderKey('2001:db8:1:2:aaaa::1', 's');
self::assertSame($a, FormRepository::senderKey('2001:db8:1:2:ffff:1:2:3', 's'));
self::assertNotSame($a, FormRepository::senderKey('2001:db8:1:3::1', 's'));
self::assertNotSame(FormRepository::senderKey('192.0.2.1', 's'), FormRepository::senderKey('192.0.2.2', 's'));
}
public function testAStampFromOneFormDoesNotOpenAnother(): void
{
// Das Siegel gilt für ein Formular; eines von einer anderen Seite lässt sich nicht
// wiederverwenden (ADR 116). Der Versand wird still verworfen wie bei jedem Bot.
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$issued = (string) (time() - 10);
$client->request('POST', '/formular/kontakt', [...$this->validBody($issued), 'issuedSignature' => FormRepository::stamp('anderes', (int) $issued, 'isolated-test-secret')]);
self::assertResponseStatusCodeSame(303);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testAFloodStopsStoringAndTellsTheVisitor(): void
{
$forms = $this->contactForm();
$this->pageWithForm();
$form = $forms->findBySlug('kontakt');
// Viele verschiedene Absender in der letzten Stunde: die Grenze je Formular greift.
for ($i = 0; $i < FormRepository::FORM_RATE_LIMIT; ++$i) {
$forms->store($form['id'], ['name' => 'x'], true, 'absender-'.$i);
}
self::assertTrue($forms->flooded($form['id']));
$client = self::createClient();
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
// Sichtbar statt still: ein echter Besucher erfährt, dass er anrufen sollte.
self::assertResponseStatusCodeSame(422);
self::assertStringContainsString('Gerade gehen sehr viele Anfragen ein', (string) $client->getResponse()->getContent());
self::assertSame(FormRepository::FORM_RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
+1 -1
View File
@@ -291,7 +291,7 @@ final class FormStepsTest extends TestCase
$issued = time() - 10;
$grund = [
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'),
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('preisfinder', $issued, 'test-only'),
'immobilienart' => 'Grundstück', 'wohnflaeche' => '99', 'ausstattung' => ['Balkon'], 'flaeche' => '640', 'email' => 'x@example.org', 'datenschutz' => 'ja',
];
$antwort = $this->request('/formular/preisfinder', 'POST', $grund);
+53 -1
View File
@@ -23,6 +23,8 @@ final class InstallFlowTest extends TestCase
{
private string $root;
private string $database;
/** @var array<string, string> Cookies wie im Browser: der Nachweis des Installationsschlüssels. */
private array $cookies = [];
protected function setUp(): void
{
@@ -58,6 +60,11 @@ final class InstallFlowTest extends TestCase
self::assertSame(302, $response->getStatusCode());
self::assertSame('/install', $response->headers->get('Location'));
// 0b. Ohne Installationsschlüssel öffnet sich kein Schritt (ADR 116).
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
self::assertSame('/install/schluessel', $this->request('/install/datenbank')->headers->get('Location'));
$this->unlock();
// 1. The system check.
$check = $this->request('/install');
self::assertSame(200, $check->getStatusCode());
@@ -92,6 +99,9 @@ final class InstallFlowTest extends TestCase
self::assertStringContainsString('Fertig.', $done->getContent());
self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent());
// Der Schlüssel hat seine Arbeit getan und liegt nicht mehr herum.
self::assertFileDoesNotExist($this->root.'/var/install.key');
// 6. The installer is over, and the website answers.
self::assertSame(404, $this->request('/install')->getStatusCode());
self::assertSame(404, $this->request('/install/konto')->getStatusCode());
@@ -105,8 +115,41 @@ final class InstallFlowTest extends TestCase
self::assertSame(200, $this->request('/admin/login')->getStatusCode());
}
public function testTheInstallerOpensOnlyWithItsKey(): void
{
// Wer die frische Installation zuerst aufruft, bekommt nur die Frage nach dem Schlüssel.
$page = $this->request('/install/schluessel');
self::assertSame(200, $page->getStatusCode());
self::assertStringContainsString('Installationsschlüssel', (string) $page->getContent());
$key = trim((string) file_get_contents($this->root.'/var/install.key'));
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
// Ein falscher Schlüssel öffnet nichts und setzt kein Cookie.
$wrong = $this->request('/install/schluessel', 'POST', ['schluessel' => str_repeat('0', 32)]);
self::assertSame(200, $wrong->getStatusCode());
self::assertStringContainsString('passt nicht', (string) $wrong->getContent());
self::assertSame([], $wrong->headers->getCookies());
// Auch ein selbst gebautes Cookie nicht.
$this->cookies = ['kant_install' => hash_hmac('sha256', 'installer', 'geraten')];
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
// Der Verweis von install.php trägt den Schlüssel in der Adresse; danach geht es ohne ihn weiter.
$this->cookies = [];
$open = $this->request('/install/schluessel?schluessel='.$key);
self::assertSame('/install', $open->headers->get('Location'));
$cookie = $open->headers->getCookies()[0];
self::assertSame('kant_install', $cookie->getName());
self::assertTrue($cookie->isHttpOnly());
self::assertSame('strict', $cookie->getSameSite());
self::assertSame('/install', $cookie->getPath());
self::assertNotSame($key, $cookie->getValue(), 'Im Cookie steht ein Nachweis, nicht der Schlüssel.');
$this->cookies = ['kant_install' => (string) $cookie->getValue()];
self::assertSame(200, $this->request('/install')->getStatusCode());
}
public function testAStepWithoutItsRequirementSendsYouBack(): void
{
$this->unlock();
// Somebody who guesses the address of a later step does not get past the missing database.
self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location'));
self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location'));
@@ -118,6 +161,15 @@ final class InstallFlowTest extends TestCase
self::assertFileDoesNotExist($this->root.'/.env');
}
/** Gibt den Installationsschlüssel ein, wie es der Betreiber tut, und behält das Cookie. */
private function unlock(): void
{
$this->request('/install/schluessel');
$response = $this->request('/install/schluessel', 'POST', ['schluessel' => trim((string) file_get_contents($this->root.'/var/install.key'))]);
self::assertSame('/install', $response->headers->get('Location'));
$this->cookies['kant_install'] = (string) $response->headers->getCookies()[0]->getValue();
}
/** Posts a form with the signature the rendered page carries, the way a browser would. */
private function submit(string $url, Response $page, array $fields): Response
{
@@ -143,7 +195,7 @@ final class InstallFlowTest extends TestCase
}
$kernel = new InstallTestKernel($this->root);
try {
$request = Request::create($url, $method, $body);
$request = Request::create($url, $method, $body, $this->cookies);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
+33
View File
@@ -12,6 +12,7 @@ use App\Domain\CmsUser;
use App\Domain\InvalidPage;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Installation;
use App\Ops\Protection;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
@@ -48,6 +49,38 @@ final class InstallationTest extends TestCase
$this->installation = $this->make();
}
public function testMissingLockFilesAreAddedButAnOwnRootFileStays(): void
{
// var/ gibt es nicht im Archiv, also legt die Website die Sperrdatei selbst an (ADR 116).
mkdir($this->root.'/config/schutz', 0o775, true);
mkdir($this->root.'/vendor', 0o775, true);
foreach (['sperren', 'stamm'] as $name) {
copy(\dirname(__DIR__).'/config/schutz/'.$name.'.htaccess', $this->root.'/config/schutz/'.$name.'.htaccess');
}
file_put_contents($this->root.'/.htaccess', '# eigene Regeln
');
Protection::reset();
Protection::ensure($this->root);
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/var/.htaccess'));
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/vendor/.htaccess'));
self::assertSame('# eigene Regeln
', file_get_contents($this->root.'/.htaccess'), 'Eine eigene Datei im Projektstamm bleibt.');
Protection::reset();
}
public function testTheInstallKeyIsCreatedOnceAndCheckedStrictly(): void
{
$key = $this->installation->installKey();
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
self::assertSame($key, $this->installation->installKey(), 'Einmal angelegt, bleibt er.');
self::assertTrue($this->installation->acceptsKey(' '.strtoupper($key).' '), 'Abgetippt mit Leerzeichen und Großbuchstaben zählt.');
self::assertFalse($this->installation->acceptsKey(substr($key, 0, 31)));
self::assertFalse($this->installation->acceptsKey(''));
self::assertTrue($this->installation->acceptsProof($this->installation->keyProof()));
self::assertFalse($this->installation->acceptsProof(''));
self::assertFalse($this->installation->acceptsProof($key));
}
protected function tearDown(): void
{
$this->db->close();
+19
View File
@@ -85,6 +85,25 @@ final class SingleFileInstallerTest extends TestCase
self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt');
}
public function testTheInstallerKeyIsWrittenForTheWebInstaller(): void
{
// install.php legt den Schlüssel dort an, wo der Installer ihn sucht (ADR 116).
$key = kant_install_key($this->target);
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
self::assertSame($key, trim((string) file_get_contents($this->target.'/var/install.key')));
self::assertSame($key, kant_install_key($this->target), 'Ein zweiter Aufruf liest denselben Schlüssel.');
}
public function testTheReleaseCarriesItsLockFiles(): void
{
// Die Sperrdateien liegen im Repository und gleichen ihrer Vorlage (ADR 116).
$root = \dirname(__DIR__);
self::assertFileEquals($root.'/config/schutz/stamm.htaccess', $root.'/.htaccess');
foreach (['bin', 'config', 'migrations', 'plugins', 'src', 'templates'] as $ordner) {
self::assertFileEquals($root.'/config/schutz/sperren.htaccess', $root.'/'.$ordner.'/.htaccess', $ordner);
}
}
public function testThePreflightNamesWhatIsMissing(): void
{
$checks = kant_checks($this->target);
+2
View File
@@ -23,6 +23,7 @@ use DoctrineMigrations\Version20260915000100;
use DoctrineMigrations\Version20260916000100;
use DoctrineMigrations\Version20260917000100;
use DoctrineMigrations\Version20261009000100;
use DoctrineMigrations\Version20261012000100;
use Psr\Log\NullLogger;
/**
@@ -57,6 +58,7 @@ final class TestSchema
Version20260916000100::class,
Version20260917000100::class,
Version20261009000100::class,
Version20261012000100::class,
] as $migration) {
(new $migration($db, new NullLogger()))->up($schema);
}
+3 -2
View File
@@ -4,6 +4,7 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
@@ -312,7 +313,7 @@ final class TranslationTest extends TestCase
public function testFormsSpeakTheLanguageOfThePageButSendInTheMainLanguage(): void
{
$this->languages->save(['en']);
$forms = new \App\Content\FormRepository($this->db);
$forms = new FormRepository($this->db);
$translations = ['en' => ['buttonLabel' => 'Send request', 'success' => 'Thanks!', 'fields' => [
'art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote', 'Gibt es nicht' => 'x']],
'email' => ['label' => 'Email', 'help' => ''],
@@ -352,7 +353,7 @@ final class TranslationTest extends TestCase
self::assertStringNotContainsString('name="language"', $german);
$issued = time() - 10;
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('anfrage', $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
$refused = $this->request('/formular/anfrage', 'POST', $body);
self::assertSame(422, $refused->getStatusCode());
self::assertStringContainsString('This field is required.', (string) $refused->getContent());