Files
KantSites/tests/FormStepsTest.php
T
kantarciandClaude Opus 5.5 ad7c333215 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>
2026-10-12 14:38:25 +02:00

352 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Domain\InvalidForm;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Formulare mit Schritten, Bedingungen und den Feldtypen Zahl, Optionsfelder und Mehrfachauswahl.
*
* Umut wollte für die Immobilienbewertung „kein Kontaktformular weiteres, sondern so ein
* ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Felder je
* Objektart, dann Kontakt und Zusammenfassung. Der Kern wurde dafür erweitert statt ein Plugin
* zu bauen; diese Tests halten fest, was der Server dabei garantiert.
*/
final class FormStepsTest extends TestCase
{
private string $path;
private Connection $db;
private FormRepository $forms;
private PageRepository $pages;
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-form-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
$this->pages = new PageRepository($this->db, new DocumentValidator(), $settings);
$this->forms = new FormRepository($this->db);
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
}
/** Ein kleiner Preisfinder: Objektart, davon abhängige Eckdaten, ein bedingter Schritt, Kontakt. */
private function preisfinder(): array
{
return [
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Womit können wir helfen?'],
['name' => 'immobilienart', 'type' => 'radio', 'label' => 'Objektart', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Grundstück']],
['name' => 'schritt_eckdaten', 'type' => 'step', 'label' => 'Eckdaten'],
['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche', 'required' => true, 'min' => 10, 'max' => 2000, 'unit' => 'm²', 'showIf' => ['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']]],
['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => true, 'options' => ['Balkon', 'Garage', 'Keller'], 'showIfField' => 'immobilienart', 'showIfValues' => 'Wohnung, Haus'],
['name' => 'schritt_grund', 'type' => 'step', 'label' => 'Grundstück', 'showIf' => ['field' => 'immobilienart', 'values' => ['Grundstück']]],
['name' => 'flaeche', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => true, 'min' => 10, 'unit' => 'm²'],
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Kontakt'],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
];
}
private function settings(): array
{
return ['recipient' => 'deeg@example.org', 'subject' => 'Bewertung', 'replyToField' => 'email', 'success' => 'Danke.', 'summary' => true];
}
public function testStepsConditionsAndNumbersAreNormalisedWhenSaving(): void
{
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
$form = $this->forms->findBySlug('preisfinder');
$fields = array_column($form['fields'], null, 'name');
// Beide Schreibweisen der Bedingung landen in derselben gespeicherten Form.
self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['wohnflaeche']['showIf']);
self::assertSame(['field' => 'immobilienart', 'values' => ['Wohnung', 'Haus']], $fields['ausstattung']['showIf']);
self::assertSame(['field' => 'immobilienart', 'values' => ['Grundstück']], $fields['schritt_grund']['showIf']);
self::assertNull($fields['email']['showIf']);
// Grenzen sind Zahlen, die Einheit bleibt; ein Schritt fragt nichts und ist nie Pflicht.
self::assertEquals(10, $fields['wohnflaeche']['min']);
self::assertEquals(2000, $fields['wohnflaeche']['max']);
self::assertSame('m²', $fields['wohnflaeche']['unit']);
self::assertNull($fields['flaeche']['max']);
self::assertFalse($fields['schritt_objekt']['required']);
self::assertTrue($form['settings']['summary']);
self::assertTrue($this->forms->needsScript($form));
// Ein gewöhnliches Kontaktformular braucht das Skript nicht - es hat weder Schritte noch
// Bedingungen, und ohne Zusammenfassung bleibt die Seite ohne JavaScript.
$this->forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'deeg@example.org'], 0);
$kontakt = $this->forms->findBySlug('kontakt');
self::assertFalse($this->forms->needsScript($kontakt));
self::assertFalse($kontakt['settings']['summary']);
self::assertNull($kontakt['fields'][0]['showIf'], 'Auch ohne Angabe kennt jedes Feld seine Schlüssel.');
}
public function testABrokenDefinitionIsRefusedWithAReason(): void
{
$faelle = [
'Bedingung auf ein unbekanntes Feld' => [
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
],
'Bedingung auf ein Feld, das erst danach kommt' => [
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
],
'Bedingung auf ein Textfeld' => [
['name' => 'art', 'type' => 'text', 'label' => 'Art'],
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Haus'],
],
'Bedingung mit einem Wert, der keine Option ist' => [
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => 'Schloss'],
],
'Bedingung ohne Wert' => [
['name' => 'art', 'type' => 'select', 'label' => 'Art', 'options' => ['Haus', 'Wohnung']],
['name' => 'a', 'type' => 'text', 'label' => 'A', 'showIfField' => 'art', 'showIfValues' => ''],
],
'Untergrenze über der Obergrenze' => [
['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => '10', 'max' => '5'],
],
'Grenze, die keine Zahl ist' => [
['name' => 'n', 'type' => 'number', 'label' => 'N', 'min' => 'zehn'],
],
'Optionsfelder mit einer Option' => [
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Ja']],
],
'Option mit Komma' => [
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['ja, neu', 'ja, alt']],
],
'nur Schritte' => [
['name' => 's', 'type' => 'step', 'label' => 'Schritt'],
],
'Bild für eine Option, die es nicht gibt' => [
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => ['Schloss' => str_repeat('a', 32)]],
],
'Bild, das keine Kennung ist' => [
['name' => 'r', 'type' => 'radio', 'label' => 'R', 'options' => ['Haus', 'Wohnung'], 'images' => 'Haus = bild.png'],
],
];
foreach ($faelle as $fall => $fields) {
try {
$this->forms->save('f', 'F', $fields, ['recipient' => 'a@example.org'], 0);
self::fail($fall.' wurde angenommen.');
} catch (InvalidForm $e) {
self::assertNotSame('', $e->getMessage(), $fall);
}
}
}
public function testOnlyFieldsWhoseConditionHoldsAreRequiredCheckedAndKept(): void
{
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
$form = $this->forms->findBySlug('preisfinder');
// Ein Grundstück: Wohnfläche und Ausstattung gelten nicht - nicht verlangt, nicht
// gespeichert, auch wenn der Browser etwas mitschickt. Der Grundstücksschritt gilt.
$grund = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'wohnflaeche' => '80', 'ausstattung' => ['Balkon'], 'flaeche' => '1250.00', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame([], $grund['errors']);
self::assertSame('', $grund['values']['wohnflaeche']);
self::assertSame('', $grund['values']['ausstattung']);
self::assertSame('1250', $grund['values']['flaeche'], 'Eine Zahl wird ohne unnötige Stellen gespeichert.');
self::assertArrayNotHasKey('schritt_objekt', $grund['values'], 'Ein Schritt ist keine Antwort.');
// Eine Wohnung: jetzt sind Wohnfläche und Ausstattung Pflicht, der Grundstücksschritt nicht.
$leer = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame('Dieses Feld wird benötigt.', $leer['errors']['wohnflaeche']);
self::assertSame('Bitte wähle mindestens eine Option.', $leer['errors']['ausstattung']);
self::assertArrayNotHasKey('flaeche', $leer['errors']);
// Zahlen mit Komma, außerhalb der Grenzen, keine Zahl; Mehrfachauswahl mit fremder Option.
$wohnung = $this->forms->check($form, ['immobilienart' => 'Wohnung', 'wohnflaeche' => '85,5', 'ausstattung' => ['Balkon', 'Keller'], 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame([], $wohnung['errors']);
self::assertSame('85.5', $wohnung['values']['wohnflaeche']);
self::assertSame('Balkon, Keller', $wohnung['values']['ausstattung']);
self::assertSame('', $wohnung['values']['flaeche']);
$zuViel = $this->forms->check($form, ['immobilienart' => 'Haus', 'wohnflaeche' => '5000', 'ausstattung' => ['Garage', 'Pool'], 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame('Bitte gib einen Wert zwischen 10 und 2000 an.', $zuViel['errors']['wohnflaeche']);
self::assertSame('Bitte wähle nur aus den angebotenen Optionen.', $zuViel['errors']['ausstattung']);
self::assertSame('Garage', $zuViel['values']['ausstattung'], 'Die fremde Option bleibt weg.');
$keineZahl = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => 'groß', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame('Bitte gib eine Zahl an.', $keineZahl['errors']['flaeche']);
$zuKlein = $this->forms->check($form, ['immobilienart' => 'Grundstück', 'flaeche' => '3', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame('Bitte gib mindestens 10 an.', $zuKlein['errors']['flaeche']);
// Eine Objektart, die es nicht gibt: abgelehnt, und nichts hängt davon ab.
$fremd = $this->forms->check($form, ['immobilienart' => 'Schloss', 'email' => 'x@example.org', 'datenschutz' => 'ja']);
self::assertSame('Bitte wähle eine der angebotenen Optionen.', $fremd['errors']['immobilienart']);
self::assertArrayNotHasKey('wohnflaeche', $fremd['errors']);
}
public function testAStepFormIsRenderedAsFieldsetsAndBringsItsScript(): void
{
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
$document = DemoContent::starter('Bewertung');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'preis', 'type' => 'form', 'props' => ['form' => 'preisfinder']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$antwort = $this->request('/');
$html = $antwort->getContent();
self::assertSame(200, $antwort->getStatusCode());
// Schritte sind Abschnitte mit Titel; ohne Skript stehen sie untereinander.
self::assertSame(4, substr_count($html, '<fieldset class="form-step"'));
self::assertStringContainsString('<legend class="form-step-title" tabindex="-1">Ihre Immobilie</legend>', $html);
self::assertStringContainsString('<p class="form-step-intro">Womit können wir helfen?</p>', $html);
self::assertStringContainsString('data-steps data-summary>', $html);
// Bedingungen stehen am Feld und am Schritt, für das Skript - der Server prüft sie selbst.
self::assertStringContainsString('data-show-if="immobilienart" data-show-values="Wohnung|Haus"', $html);
self::assertStringContainsString('<fieldset class="form-step" data-step data-show-if="immobilienart" data-show-values="Grundstück">', $html);
// Optionsfelder, Mehrfachauswahl und Zahl mit Einheit.
self::assertStringContainsString('type="radio" value="Wohnung" required', $html);
self::assertStringContainsString('name="ausstattung[]" type="checkbox" value="Balkon"', $html);
self::assertStringContainsString('data-required', $html);
self::assertStringContainsString('type="number" step="any" inputmode="decimal"', $html);
self::assertStringContainsString('min="10"', $html);
self::assertStringContainsString('<span class="form-unit" aria-hidden="true">m²</span>', $html);
// Das Skript kommt nur mit diesem Formular, und die Richtlinie erlaubt es dann.
self::assertStringContainsString('<script src="/assets/form-steps.js?v=', $html);
self::assertStringContainsString("script-src 'self'", (string) $antwort->headers->get('Content-Security-Policy'));
// Ein gewöhnliches Formular bleibt ohne Skript und ohne Erlaubnis dafür.
$this->forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'deeg@example.org'], 0);
$schlicht = DemoContent::starter('Kontakt');
$schlicht['schemaVersion'] = 23;
$schlicht['blocks'][] = ['id' => 'kontakt', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$version = $this->pages->findForEditor('page-1')['version'];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $schlicht, $version), $version + 1);
$zweite = $this->request('/');
self::assertStringNotContainsString('form-steps.js', $zweite->getContent());
self::assertStringNotContainsString('<fieldset', $zweite->getContent());
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
}
public function testAChoiceWithPicturesBecomesClickableCards(): void
{
// Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im
// Haushalt“ als vier Bildkarten. Ein Bild je Option macht aus der Auswahl Karten;
// das Kästchen bleibt darin, für Tastatur und Vorlesesoftware.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-option-');
$image = imagecreatetruecolor(800, 600);
imagepng($image, $file);
imagedestroy($image);
$bild = $media->upload(new \Symfony\Component\HttpFoundation\File\UploadedFile($file, 'haus.png', test: true));
@unlink($file);
// Aus der Verwaltung kommt die Zuordnung als Zeilen, gespeichert wird sie als Paare.
$this->forms->save('personen', 'Personen', [
['name' => 'art', 'type' => 'radio', 'label' => 'Objektart', 'required' => true, 'options' => ['Haus', 'Wohnung'], 'images' => "Haus = {$bild['id']}\nWohnung = "],
], ['recipient' => 'deeg@example.org'], 0);
$form = $this->forms->findBySlug('personen');
self::assertSame(['Haus' => $bild['id']], $form['fields'][0]['images']);
$document = DemoContent::starter('Personen');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'p', 'type' => 'form', 'props' => ['form' => 'personen']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('form-choice form-choice-images', $html);
self::assertSame(2, substr_count($html, 'class="form-choice-card"'));
// Die Option mit Bild zeigt es in der kleinsten Fassung; die ohne Bild bleibt eine Karte mit Text.
self::assertStringContainsString('<img src="'.$bild['variants'][0]['src'].'" alt="" width="480"', $html);
self::assertStringContainsString('<span class="form-choice-text">Wohnung</span>', $html);
self::assertSame(1, substr_count($html, '<img src="/media/'), 'Nur die Option mit Bild bekommt eines.');
// Keine Schritte, keine Bedingungen: die Bildauswahl braucht kein Skript.
self::assertStringNotContainsString('form-steps.js', $html);
}
public function testASubmissionStoresOnlyWhatAppliedAndReRendersWithErrors(): void
{
$this->forms->save('preisfinder', 'Preisfinder', $this->preisfinder(), $this->settings(), 0);
$document = DemoContent::starter('Bewertung');
$document['schemaVersion'] = 23;
$document['blocks'][] = ['id' => 'preis', 'type' => 'form', 'props' => ['form' => 'preisfinder']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$issued = time() - 10;
$grund = [
'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);
self::assertSame(303, $antwort->getStatusCode());
self::assertSame('/?gesendet=preisfinder#preis', $antwort->headers->get('Location'));
$gespeichert = json_decode((string) $this->db->fetchOne('SELECT data FROM form_submission'), true);
self::assertSame('Grundstück', $gespeichert['immobilienart']);
self::assertSame('640', $gespeichert['flaeche']);
self::assertSame('', $gespeichert['wohnflaeche'], 'Was nicht galt, wird nicht gespeichert.');
self::assertSame('', $gespeichert['ausstattung']);
// Eine Wohnung ohne Wohnfläche: die Seite kommt mit Meldungen zurück, die gewählte
// Objektart bleibt angekreuzt, die Mehrfachauswahl behält ihre Kreuze.
$wohnung = [...$grund, 'immobilienart' => 'Wohnung', 'wohnflaeche' => '', 'ausstattung' => ['Balkon', 'Keller'], 'flaeche' => ''];
$abgelehnt = $this->request('/formular/preisfinder', 'POST', $wohnung);
self::assertSame(422, $abgelehnt->getStatusCode());
$html = $abgelehnt->getContent();
self::assertStringContainsString('Bitte prüfe 1 Eingabe.', $html);
self::assertStringContainsString('type="radio" value="Wohnung" checked required', $html);
self::assertStringContainsString('value="Balkon" checked', $html);
self::assertStringContainsString('value="Keller" checked', $html);
self::assertStringContainsString('value="Garage">', $html);
self::assertStringContainsString('Dieses Feld wird benötigt.', $html);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), 'Eine abgelehnte Eingabe wird nicht gespeichert.');
}
private function request(string $url, string $method = 'GET', array $body = []): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
$kernel = new Kernel('test', false);
try {
$request = Request::create($url, $method, $body);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
} finally {
$kernel->shutdown();
foreach ($previous as $key => [$server, $env]) {
if (null === $server) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $server;
}
if (null === $env) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $env;
}
}
}
}
}