Files
KantSites/tests/EditorSecurityTest.php
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

1322 lines
83 KiB
PHP

<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\PostRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidForm;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Security\UserProvider;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class EditorSecurityTest extends WebTestCase
{
private Connection $db;
private string $path;
private array $previous;
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-auth-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
$pages->publish('p1', $revision, 1);
// The navigation is part of the header area, so the fixture needs one.
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
foreach (['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->db->close();
gc_collect_cycles();
unlink($this->path);
foreach (glob($this->path.'-media/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->path.'-media')) {
rmdir($this->path.'-media');
}
[$_SERVER, $_ENV] = $this->previous;
}
/** Ein Client je Test (der Kernel startet nur einmal); ein zweiter Benutzer meldet sich im selben an. */
private function login(string $username = 'admin', ?KernelBrowser $client = null): KernelBrowser
{
$client ??= self::createClient();
$client->getCookieJar()->clear();
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password']));
self::assertResponseRedirects('/admin');
$client->followRedirect();
self::assertResponseIsSuccessful();
return $client;
}
public function testTheSessionCookieFollowsARenamedAdministration(): void
{
// Heisst die Verwaltung nicht mehr /admin, muss das Sitzungscookie auf ihren Pfad lauten -
// sonst schickt der Browser es nie mit, und jede Anmeldung landet wieder vor der Tuer.
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
$client = self::createClient();
$crawler = $client->request('GET', '/stiller-eingang/login');
self::assertResponseIsSuccessful();
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'Fixture-only-password']));
self::assertResponseRedirects('/stiller-eingang');
// Im Test heisst das Sitzungscookie anders als im Betrieb; der Pfad ist, was zaehlt.
$paths = array_map(static fn ($cookie): string => $cookie->getPath(), $client->getCookieJar()->all());
self::assertContains('/stiller-eingang', $paths, 'Das Sitzungscookie muss fuer den neuen Pfad gelten.');
self::assertNotContains('/admin', $paths);
$client->followRedirect();
self::assertResponseIsSuccessful();
self::assertSelectorExists('a[href="/stiller-eingang/logout"], form[action="/stiller-eingang/logout"]');
}
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
{
$client = $this->login();
$crawler = $client->request('GET', '/admin/settings');
self::assertSelectorExists('#not-found-picker[role="combobox"]');
$form = $crawler->selectButton('404-Vorlage anlegen')->form();
$values = $form->getPhpValues();
$client->request('POST', '/admin/settings/not-found', [...$values, '_csrf_token' => 'invalid']);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$client->submit($form);
self::assertResponseRedirects();
$id = json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId'];
self::assertResponseRedirects('/admin/pages/'.$id);
self::assertNull($this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]));
$client->request('POST', '/admin/settings/not-found', ['_csrf_token' => $values['_csrf_token'], 'notFoundPageId' => '', 'expectedId' => '']);
self::assertSame($id, json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
$client->followRedirect();
self::assertSelectorTextContains('body', 'inzwischen geändert');
$crawler = $client->request('GET', '/admin/settings');
$client->submit($crawler->selectButton('404-Zuordnung speichern')->form(['notFoundPageId' => '']));
self::assertSame('', json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
}
public function test404AssignmentRequiresPublicationRights(): void
{
// Ein Redakteur sieht die Einstellungen gar nicht erst: alles dort wirkt sofort.
$client = $this->login('editor');
$client->request('GET', '/admin/settings');
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
self::assertResponseStatusCodeSame(403);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
// Ein Veroeffentlicher darf die 404-Seite zuordnen, aber die Unternehmensdaten nicht aendern.
$this->login('publisher', $client);
$client->request('GET', '/admin/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('#not-found-form');
self::assertSelectorNotExists('form[action="/admin/settings/company"]');
}
/**
* Wer was aendern darf, steht in der Rollenhilfe - und hier, damit es so bleibt.
*
* Jede Zeile: eine Route, die Rolle, die zu wenig ist, und die Antwort 403. Ohne Anmeldung
* fuehrt jede dieser Adressen zur Anmeldung. Die Rollen, die duerfen, pruefen die uebrigen Tests.
*/
public function testEveryLiveChangeNeedsTheRoleTheRoleHelpNames(): void
{
$tooLittle = [
// Design, Unternehmensdaten, System, Benutzer, endgueltiges Loeschen: Administrator.
['publisher', 'GET', '/admin/theme'], ['publisher', 'POST', '/admin/theme/save'], ['publisher', 'POST', '/admin/theme/fonts'],
['publisher', 'POST', '/admin/settings/company'],
['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'],
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
// Ein Plugin bringt Code auf die Website: Administrator.
['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'],
['publisher', 'POST', '/admin/pages/p1/delete'],
// Alles, was sofort auf der Website steht: Veroeffentlicher.
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
['editor', 'GET', '/admin/partials'], ['editor', 'POST', '/admin/partials/new'],
['editor', 'GET', '/admin/menus'], ['editor', 'POST', '/admin/menus/header'],
['editor', 'GET', '/admin/forms'], ['editor', 'POST', '/admin/forms/neu'],
['editor', 'POST', '/admin/pages/p1/schedule'], ['editor', 'POST', '/admin/api/pages/p1/publish'],
];
$client = null;
foreach (['publisher', 'editor'] as $role) {
$client = $this->login($role, $client);
foreach (array_filter($tooLittle, static fn (array $row): bool => $row[0] === $role) as [, $method, $path]) {
$client->request($method, $path);
self::assertSame(403, $client->getResponse()->getStatusCode(), sprintf('%s %s %s', $role, $method, $path));
}
}
// Ein Redakteur sieht in der Navigation nur, was er oeffnen darf.
$crawler = $client->request('GET', '/admin');
self::assertResponseIsSuccessful();
$links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href'));
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/plugins', '/admin/benutzer'] as $hidden) {
self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.');
}
self::assertContains('/admin/media', $links);
// Ohne Anmeldung: keine 403-Auskunft, sondern die Anmeldung.
$anonymous = $client;
$anonymous->getCookieJar()->clear();
foreach ([['GET', '/admin'], ['POST', '/admin/api/pages/p1/draft'], ['POST', '/admin/theme/save'], ['POST', '/admin/menus/header']] as [$method, $path]) {
$anonymous->request($method, $path);
self::assertResponseRedirects('http://localhost/admin/login', null, $method.' '.$path);
}
// Und ohne Anmeldung ist nichts geschrieben worden.
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
}
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
{
$client = $this->login();
$partials = new PartialRepository($this->db, new DocumentValidator());
$partials->create('section', 'shared-test', 'Gemeinsamer Abschnitt', DemoContent::areas()['footer']['document']);
foreach ($partials->all() as $partial) {
$base = '/admin/partials/'.$partial['id'];
$client->request('GET', $base);
self::assertResponseIsSuccessful();
self::assertSelectorExists('#editor[data-api="'.$base.'"]');
self::assertSelectorNotExists('#publish');
$client->request('GET', $base.'/document');
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
$document = $data['document'];
$document['title'] .= ' bearbeitet';
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']];
$body = json_encode(['document' => $document, 'expectedVersion' => $data['version']], JSON_THROW_ON_ERROR);
$client->request('POST', $base.'/preview', server: $headers, content: $body);
self::assertResponseIsSuccessful();
$client->request('POST', $base.'/draft', server: $headers, content: $body);
self::assertResponseIsSuccessful();
$client->request('POST', $base.'/draft', server: $headers, content: $body);
self::assertResponseStatusCodeSame(409);
$client->request('GET', $base.'/document');
self::assertSame($document['title'], json_decode($client->getResponse()->getContent(), true)['document']['title']);
}
}
private function data(KernelBrowser $client): array
{
$client->request('GET', '/admin/api/pages/p1');
self::assertResponseIsSuccessful();
return json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
}
private function post(KernelBrowser $client, string $action, array $data, string $token = ''): void
{
$client->request('POST', '/admin/api/pages/p1/'.$action, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($data));
}
public function testAnonymousCannotReadDraftOrWrite(): void
{
$client = self::createClient();
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms', '/admin/statistik', '/admin/statistik.csv'] as $url) {
$client->request('GET', $url);
self::assertResponseRedirects('http://localhost/admin/login');
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
}
foreach (['draft', 'publish', 'preview'] as $action) {
$this->post($client, $action, []);
self::assertResponseRedirects('http://localhost/admin/login');
}
foreach ([['/admin/pages', ['title' => 'Geschmuggelt', 'slug' => 'geschmuggelt']], ['/admin/pages/p1/delete', ['expectedVersion' => 2]], ['/admin/menus/header', ['name' => 'X', 'expectedVersion' => 0]], ['/admin/api/style-presets', ['name' => 'X']]] as [$url, $body]) {
$client->request('POST', $url, $body);
self::assertResponseRedirects('http://localhost/admin/login');
}
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
{
$client = self::createClient();
foreach (['/admin', '/admin/posts', '/admin/media', '/admin/api/media/'.str_repeat('a', 32)] as $url) {
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
self::assertResponseRedirects('http://localhost/admin/login');
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
}
}
public function testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId(): void
{
// Umut: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild"
$client = $this->login('editor');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$id = $pages->createPage('mit-bild', null, 'post', DemoContent::pages()['home']);
$media = str_repeat('c', 32);
$this->db->insert('media', ['id' => $media, 'title' => 'Klassenzimmer', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$media.'/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
$this->db->executeStatement('UPDATE page SET featured_media_id = ? WHERE id = ?', [$media, $id]);
$crawler = $client->request('GET', '/admin/posts');
self::assertResponseIsSuccessful();
self::assertStringNotContainsString('ID aus der Mediathek', $client->getResponse()->getContent());
self::assertCount(1, $crawler->filter('script[src^="/assets/media-field.js"]'));
// Das gewählte Beitragsbild: Kennung versteckt, Vorschau und Titel sichtbar.
$featured = $crawler->filter('input[name="featuredMediaId"]')->closest('[data-media-field]');
self::assertCount(1, $featured);
self::assertSame('hidden', $featured->filter('input[name="featuredMediaId"]')->attr('type'));
self::assertSame($media, $featured->filter('input[name="featuredMediaId"]')->attr('value'));
self::assertSame('/media/'.$media.'/480', $featured->filter('img')->attr('src'));
self::assertStringContainsString('Klassenzimmer', $featured->text());
self::assertStringContainsString('Anderes Bild wählen', $featured->text());
// Ohne Vorschaubild: Wählen statt Kennung eintippen.
$og = $crawler->filter('input[name="ogMediaId"]')->closest('[data-media-field]');
self::assertCount(1, $og);
self::assertStringContainsString('Bild wählen oder hochladen', $og->text());
self::assertNull($og->filter('img')->attr('src'));
}
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
{
$client = $this->login('editor');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
foreach (['page', 'post'] as $type) {
$document = DemoContent::pages()['home'];
$document['title'] = 'Suchprobe Über uns <img src=x onerror=alert(1)> '.$type;
$pages->createPage('suchprobe-'.$type, null, $type, $document);
}
foreach (['/admin' => 'page', '/admin/posts' => 'post'] as $url => $type) {
$headers = ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'];
$client->request('GET', $url.'?q=suchprobe&status=draft&seite=99&limit=50', server: $headers);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/json');
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame(1, $data['total']);
self::assertSame(['q' => 'suchprobe', 'status' => 'draft', 'limit' => 50, 'seite' => 1], $data['parameters']);
self::assertStringContainsString('suchprobe-'.$type, $data['html']);
self::assertStringNotContainsString('suchprobe-'.('page' === $type ? 'post' : 'page'), $data['html']);
self::assertStringNotContainsString('<img src=x', $data['html']);
self::assertStringContainsString('&lt;img src=x', $data['html']);
self::assertStringNotContainsString('delete-panel', $data['html']);
self::assertStringNotContainsString('<html', $data['html']);
self::assertStringContainsString('name="_list_query" value="suchprobe"', $data['html']);
$client->request('GET', $url.'?q='.rawurlencode('über uns'), server: $headers);
self::assertSame(1, json_decode($client->getResponse()->getContent(), true)['total']);
$client->request('GET', $url.'?q=suchprobe&status=published', server: $headers);
self::assertSame(0, json_decode($client->getResponse()->getContent(), true)['total']);
}
}
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
{
$client = $this->login('editor');
$client->request('POST', '/admin/pages', ['title' => 'Ohne Token', 'slug' => 'ohne-token', '_csrf_token' => 'wrong']);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.create-form')->form(['title' => 'Über uns', 'slug' => '', 'type' => 'page', 'parent_id' => '']));
self::assertResponseRedirects();
// The address is derived from the title when the field is left empty.
self::assertSame('ueber-uns', $this->db->fetchOne('SELECT path FROM page WHERE slug <> ?', ['home']));
// A new page is a draft, so it must not answer publicly yet.
$client->request('GET', '/ueber-uns');
self::assertResponseStatusCodeSame(404);
}
public function testEditorsAreNeitherOfferedNorAllowedToDelete(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin');
self::assertCount(0, $crawler->filter('.delete-panel'), 'An editor must not be offered the delete action.');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => $crawler->filter('input[name="_csrf_token"]')->last()->attr('value'), 'expectedVersion' => 2]);
self::assertResponseStatusCodeSame(403);
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');
$client->request('POST', '/admin/pages/p1/delete', ['_csrf_token' => 'wrong', 'expectedVersion' => 2]);
self::assertResponseRedirects('/admin');
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->filter('.delete-panel form')->form());
self::assertResponseRedirects('/admin');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
$client->request('GET', '/');
self::assertResponseStatusCodeSame(404);
}
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
self::assertResponseIsSuccessful();
// Rows are ordered by position and nested by level, so the form needs no JavaScript.
$client->request('POST', '/admin/menus/header', [
'_csrf_token' => $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value'),
'name' => 'Hauptnavigation',
'expectedVersion' => 0,
'items' => [
['position' => 30, 'level' => 1, 'label' => 'Extern', 'type' => 'url', 'url' => 'https://hausklar.app'],
['position' => 10, 'level' => 1, 'label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''],
['position' => 20, 'level' => 2, 'label' => 'Kontakt', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '#kontakt'],
['position' => 40, 'level' => 1, 'label' => ' ', 'type' => 'page', 'pageId' => 'p1'],
],
]);
self::assertResponseRedirects('/admin/menus');
$items = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true);
self::assertSame(['Start', 'Extern'], array_column($items, 'label'));
self::assertSame('Kontakt', $items[0]['children'][0]['label']);
// A leading hash in the anchor field is accepted and normalised away.
self::assertSame('kontakt', $items[0]['children'][0]['anchor']);
// The blank row is an add slot, not an entry.
self::assertCount(2, $items);
$public = $client->request('GET', '/')->filter('nav[aria-label="Hauptnavigation"] > .menu-list a');
// The navigation comes from the menu now, not from the links hard-coded in the template.
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
}
/**
* ADR 110: Ein Menüeintrag wird über das Formular zum Button - mit Gestalt, Farben, Symbol,
* Bewegung und Anzeige auf dem Telefon. Die Kennung für die Farbregel entsteht beim ersten
* Speichern und bleibt danach gleich; leere Felder heißen Website-Standard und fehlen.
*/
public function testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$row = ['position' => 10, 'level' => 1, 'label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt',
'icon' => 'arrow-right', 'iconPosition' => 'after', 'button' => '1', 'buttonId' => '', 'buttonShape' => 'pill', 'buttonAppearance' => '',
'buttonSize' => '', 'buttonFill' => '#b71c22', 'buttonInk' => '', 'buttonHoverFill' => '@ink', 'buttonHoverInk' => '', 'buttonIconColor' => '',
'iconMotion' => 'slide', 'buttonMobile' => 'bar'];
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [$row]]);
self::assertResponseRedirects('/admin/menus');
$button = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button'];
self::assertMatchesRegularExpression('/^m[a-f0-9]{10}$/', $button['id']);
self::assertSame(['shape' => 'pill', 'fill' => '#b71c22', 'hoverFill' => '@ink', 'mobile' => 'bar', 'iconMotion' => 'slide'], array_diff_key($button, ['id' => true]));
// Die Verwaltung zeigt die Werte wieder an, und erneutes Speichern behält die Kennung.
$crawler = $client->request('GET', '/admin/menus');
self::assertSame($button['id'], $crawler->filter('input[data-key="buttonId"]')->attr('value'));
self::assertSame('pill', $crawler->filter('select[data-key="buttonShape"] option[selected]')->attr('value'));
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 1, 'items' => [[...$row, 'buttonId' => $button['id']]]]);
self::assertSame($button['id'], json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']['id']);
// Ohne Häkchen ist es wieder ein Link.
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 2, 'items' => [array_diff_key($row, ['button' => true])]]);
self::assertArrayNotHasKey('button', json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]);
}
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
{
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
$client->request('POST', '/admin/menus/header', ['_csrf_token' => 'wrong', 'name' => 'X', 'expectedVersion' => 1, 'items' => [$entry]]);
self::assertResponseRedirects('/admin/menus');
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
// A stale version must not overwrite a newer menu.
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Veraltet', 'expectedVersion' => 0, 'items' => [$entry]]);
self::assertResponseRedirects('/admin/menus');
self::assertSame('Hauptnavigation', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
// An unsafe target is refused with a message instead of being stored.
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [['position' => 10, 'level' => 1, 'label' => 'Boese', 'type' => 'url', 'url' => 'javascript:alert(1)']]]);
$client->followRedirect();
self::assertSelectorExists('.notice.error');
self::assertStringNotContainsString('javascript:', $this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']));
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Neu', 'expectedVersion' => 1, 'items' => [$entry]]);
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
}
/** @param array<string, string> $settings overrides for the mail settings */
private function contactForm(array $settings = []): FormRepository
{
$forms = new FormRepository($this->db);
$forms->save('kontakt', 'Kontakt', [
['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'],
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.', ...$settings], 0);
return $forms;
}
/** Publishes a page carrying the contact form block and returns the page path. */
private function pageWithForm(): string
{
$document = DemoContent::starter('Kontakt');
$document['schemaVersion'] = 8;
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
$pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
return 'home';
}
/** @return array<string, string> a body that would pass every check */
private function validBody(string $issued): array
{
return [
'page' => 'home', 'block' => 'formblock', 'website' => '',
'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',
];
}
public function testFormRendersWithoutScriptAndStoresAValidSubmissionWithConsent(): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$html = $client->request('GET', '/')->html();
self::assertStringContainsString('action="/formular/kontakt"', $html);
self::assertStringContainsString('<button class="button cms-button" type="submit">Absenden</button>', $html);
self::assertStringContainsString('Ich willige ein.', $html);
// The honeypot exists but is kept out of the tab order and away from assistive technology.
self::assertStringContainsString('name="website"', $html);
self::assertStringContainsString('tabindex="-1"', $html);
self::assertStringNotContainsString('<script', $client->getResponse()->getContent());
$issued = (string) (time() - 10);
$client->request('POST', '/formular/kontakt', $this->validBody($issued));
// Post/Redirect/Get, so reloading the result page sends nothing twice.
self::assertResponseStatusCodeSame(303);
self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location'));
// Ohne eigene Absenderadresse geht die Post vom Empfaenger an sich selbst.
$mail = self::getMailerMessage();
self::assertNotNull($mail);
self::assertEmailAddressContains($mail, 'From', 'info@kant-it.de');
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
$row = $this->db->fetchAssociative('SELECT * FROM form_submission');
self::assertNotFalse($row);
$data = json_decode($row['data'], true);
self::assertSame('Umut', $data['name']);
self::assertSame('ja', $data['datenschutz']);
// The moment of consent is stored, the sender address never is.
self::assertNotNull($row['consent_at']);
self::assertSame(64, strlen($row['sender_hash']));
self::assertStringNotContainsString('127.0.0.1', json_encode($row));
// The success message appears on the page the form sits on.
$client->request('GET', '/?gesendet=kontakt');
self::assertStringContainsString('Danke.', $client->getResponse()->getContent());
}
public function testAFormMaySendFromItsOwnAddress(): void
{
// Umut: die Versandadresse soll sich festlegen lassen - bisher war sie immer der
// Empfaenger, und manche Hoster nehmen fremde Absender gar nicht erst an.
$forms = $this->contactForm(['sender' => 'formular@kant-it.de']);
$this->pageWithForm();
$client = self::createClient();
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
self::assertResponseStatusCodeSame(303);
$mail = self::getMailerMessage();
self::assertNotNull($mail);
self::assertEmailAddressContains($mail, 'From', 'formular@kant-it.de');
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
// Eine kaputte Absenderadresse wird abgewiesen, eine leere bedeutet: wie der Empfaenger.
try {
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => 'kein-postfach'], 1);
self::fail('Eine ungueltige Absenderadresse darf nicht gespeichert werden.');
} catch (InvalidForm $e) {
self::assertStringContainsString('Absenderadresse', $e->getMessage());
}
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => ' '], 1);
self::assertSame('', $forms->findBySlug('kontakt')['settings']['sender']);
}
public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$issued = (string) (time() - 10);
$body = [...$this->validBody($issued), 'email' => 'keine-adresse', 'thema' => 'Erfunden', 'name' => '', 'datenschutz' => ''];
$crawler = $client->request('POST', '/formular/kontakt', $body);
self::assertResponseStatusCodeSame(422);
// Nothing is stored and nothing is sent while anything is wrong.
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
$html = $client->getResponse()->getContent();
self::assertStringContainsString('Bitte gib eine gültige E-Mail-Adresse an.', $html);
self::assertStringContainsString('Bitte wähle eine der angebotenen Optionen.', $html);
self::assertStringContainsString('Dieses Feld wird benötigt.', $html);
self::assertStringContainsString('Ohne diese Einwilligung', $html);
// Each message belongs to its field and is announced as such.
self::assertSame('formblock-email-error', $crawler->filter('#formblock-email')->attr('aria-describedby'));
self::assertSame('true', $crawler->filter('#formblock-email')->attr('aria-invalid'));
// Und die Seite sieht aus wie die Seite: Gestaltung, Kopf- und Fussbereich sind da.
// Vorher baute der Formularcode die Seite selbst noch einmal auf und liess dabei Theme,
// Blockstile und Schriften weg - wer ein Pflichtfeld vergass, sah eine nackte Seite.
self::assertStringContainsString('<style>', $html, 'Die Gestaltung fehlt auf der Fehlerseite.');
self::assertStringContainsString('.kant{font-family', $html);
self::assertCount(1, $crawler->filter('[role=alert]'));
// The entries survive the rejection.
self::assertSame('keine-adresse', $crawler->filter('#formblock-email')->attr('value'));
self::assertStringContainsString('Bitte um Rückruf.', $crawler->filter('#formblock-nachricht')->text());
}
/**
* @return iterable<string, array{callable(string): array}>
*/
public static function droppedSubmissions(): iterable
{
$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']];
yield 'timestamp far in the past' => [static function (array $body) use ($sign): array {
$old = (string) (time() - 200000);
return [...$body, 'issued' => $old, 'issuedSignature' => $sign($old)];
}];
}
#[DataProvider('droppedSubmissions')]
public function testSpamProtectionDropsSubmissionsQuietlyWithoutStoringThem(callable $tamper): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$client->request('POST', '/formular/kontakt', $tamper($this->validBody((string) (time() - 10))));
// Answered like a success so an automated sender learns nothing from the response.
self::assertResponseStatusCodeSame(303);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testRateLimitStopsTheSixthSubmissionWithinAnHour(): void
{
$forms = $this->contactForm();
$this->pageWithForm();
$client = self::createClient();
for ($i = 0; $i < FormRepository::RATE_LIMIT; ++$i) {
$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'));
$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(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
{
$forms = $this->contactForm();
$form = $forms->findBySlug('kontakt');
// Ohne Gestaltung keine Regel: die Website gilt.
self::assertSame('', $forms->css($form, (new Theme($this->db))->load()));
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
$this->pageWithForm();
$client = $this->login('publisher');
$crawler = $client->request('GET', '/');
self::assertResponseIsSuccessful();
$html = (string) $client->getResponse()->getContent();
self::assertSame('kontakt', $crawler->filter('.block-form')->attr('data-form'));
self::assertSame('Anfrage senden', $crawler->filter('.block-form button[type="submit"]')->text());
// Rahmen und Grund der Felder direkt, der Button ueber die Token des Standardbuttons - und
// die Ableitung der Darstellung noch einmal, damit sie diese Fuellung nimmt, nicht die vom
// Wurzelelement.
preg_match('/\.block-form\[data-form="kontakt"\]\{([^}]*)\}/', $html, $rule);
self::assertStringContainsString('--form-border:var(--k-ink)', $rule[1] ?? '');
self::assertStringContainsString('--form-bg:#fffdf7', $rule[1] ?? '');
self::assertStringContainsString('--kb-fill:#124b36', $rule[1] ?? '');
self::assertStringContainsString('--kb-bg:var(--kb-fill)', $rule[1] ?? '');
self::assertStringNotContainsString('--kb-ink:', $rule[1] ?? '', 'Nur gesetzte Farben werden geschrieben.');
// Die Regel steht im gehashten Stylesblock, den die Richtlinie erlaubt.
preg_match('#<style>(.*?)</style>#s', $html, $style);
self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy'));
// Die Übersicht listet das Formular, bearbeitet wird es auf seiner eigenen Seite.
$crawler = $client->request('GET', '/admin/forms');
self::assertSame(0, $crawler->filter('.form-def')->count(), 'Die Übersicht zeigt keine Editoren.');
self::assertSame('/admin/forms/kontakt', $crawler->filter('.form-list a')->first()->attr('href'));
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
$crawler = $client->request('GET', '/admin/forms/kontakt');
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.');
self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text());
$client->request('POST', '/admin/forms/kontakt', ['_csrf_token' => $token, 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 2, 'buttonColor' => 'rot',
'fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]);
$client->followRedirect();
self::assertSelectorTextContains('.notice.error', 'kein Farbwert für den Button');
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
}
public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/forms/neu');
self::assertResponseIsSuccessful();
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
$field = ['fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]];
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => 'wrong', 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
self::assertResponseRedirects('/admin/forms');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
// A recipient that is not an address is refused with a message rather than stored.
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'kein-empfaenger', 'expectedVersion' => 0]);
$client->followRedirect();
self::assertSelectorExists('.notice.error');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
}
public function testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses(): void
{
// Was ein Baustein nur verknüpft, kommt als Liste Adresse => Name - der Editor macht
// daraus Auswahlfelder statt Textfelder für Adressen.
$media = new MediaLibrary($this->db, $this->path.'-media');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media));
(new FormRepository($this->db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => '', 'options' => []]], ['recipient' => 'info@example.com'], 0);
$band = DemoContent::starter('Werbeband');
$band['blocks'] = [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Aktion']]];
(new PartialRepository($this->db, new DocumentValidator()))->create('section', 'band', 'Werbeband', $band);
$post = $pages->createPage('ein-beitrag', null, 'post', DemoContent::pages()['home']);
(new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']);
$client = $this->login('editor');
$client->request('GET', '/admin/api/design');
self::assertResponseIsSuccessful();
$choices = json_decode($client->getResponse()->getContent(), true)['choices'];
self::assertSame(['anfrage' => 'Anfrage'], $choices['forms']);
self::assertSame(['band' => 'Werbeband'], $choices['sections']);
self::assertSame(['projekte' => 'Projekte'], $choices['categories']);
self::assertSame(['umbau' => 'Umbau'], $choices['tags']);
}
public function testMenuAnchorsAndPostTermsAreOfferedForChoosing(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media));
$document = DemoContent::starter('Leistungen');
$document['schemaVersion'] = 21;
$document['blocks'][] = ['id' => 'kontakt-sektion', 'type' => 'section', 'anchor' => 'kontakt', 'children' => [
['id' => 'kontakt-titel', 'type' => 'heading', 'props' => ['text' => 'So erreichen Sie uns', 'level' => 2]],
]];
$id = $pages->createPage('leistungen', null, 'page', $document);
// Die Sprungziele einer Seite, beschriftet mit ihrer Überschrift - nur für Rollen, die
// Menüs bearbeiten.
$client = $this->login('editor');
$client->request('GET', '/admin/api/sprungziele/'.$id);
self::assertResponseStatusCodeSame(403);
$client = $this->login('admin', $client);
$client->request('GET', '/admin/api/sprungziele/'.$id);
self::assertResponseIsSuccessful();
self::assertSame([['anchor' => 'kontakt', 'label' => 'So erreichen Sie uns (#kontakt)']], json_decode($client->getResponse()->getContent(), true)['items']);
$client->request('GET', '/admin/api/sprungziele/gibt-es-nicht');
self::assertResponseStatusCodeSame(404);
// Vorhandene Kategorien und Schlagwörter stehen am Feld, damit man sie wählt statt neu tippt.
$post = $pages->createPage('beitrag', null, 'post', DemoContent::pages()['home']);
(new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']);
$crawler = $client->request('GET', '/admin/posts');
self::assertSame(['Projekte'], json_decode($crawler->filter('input[name="categories"]')->attr('data-term-input'), true));
self::assertSame(['Umbau'], json_decode($crawler->filter('input[name="tags"]')->attr('data-term-input'), true));
self::assertCount(1, $crawler->filter('script[src^="/assets/term-chips.js"]'));
}
public function testPluginSettingsForLinksAndImagesAreChosenNotTyped(): void
{
// Das Feld einer Plugin-Einstellung, direkt aus dem Makro der Plugin-Seite: ein Link
// bleibt ein Textfeld mit Knöpfen daneben, ein Bild wird das Bildfeld der Mediathek.
self::bootKernel();
$twig = static::getContainer()->get('twig');
$render = static fn (array $schema, string $value): string => $twig->createTemplate(
"{% import 'admin/plugins.html.twig' as f %}{{ f.feld('probe', 'ziel', schema, value) }}",
)->render(['schema' => $schema, 'value' => $value]);
$link = $render(['kind' => 'url', 'label' => 'Ziel', 'maxLength' => 200, 'help' => ''], '/kontakt');
self::assertStringContainsString('data-link-field', $link);
self::assertMatchesRegularExpression('#<input id="plugin-probe-ziel" name="settings\[ziel\]" value="/kontakt"[^>]*data-link-input#', $link);
$bild = $render(['kind' => 'media', 'label' => 'Bild', 'help' => ''], '');
self::assertStringContainsString('data-media-field', $bild);
self::assertStringContainsString('<input type="hidden" id="plugin-probe-ziel" name="settings[ziel]" value="">', $bild);
self::assertStringNotContainsString('Kennung aus der Mediathek', $bild);
}
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
{
$client = $this->login('editor');
$client->request('GET', '/admin/api/design');
self::assertResponseIsSuccessful();
$design = json_decode($client->getResponse()->getContent(), true);
// The editor receives the vocabulary, the palette and the presets from one place.
self::assertArrayHasKey('fontSize', $design['properties']);
self::assertSame([], $design['presets']);
$token = $design['csrfToken'];
$post = function (array $body, string $csrf) use ($client): void {
$client->request('POST', '/admin/api/style-presets', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $csrf], content: json_encode($body));
};
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px']]], 'wrong');
self::assertResponseStatusCodeSame(403);
// A style the documents would refuse cannot arrive through a preset either.
$post(['name' => 'Boese', 'style' => ['desktop' => ['fontSize' => '32px;position:fixed']]], $token);
self::assertResponseStatusCodeSame(422);
$post(['name' => ' ', 'style' => ['desktop' => ['fontSize' => '32px']]], $token);
self::assertResponseStatusCodeSame(422);
self::assertStringNotContainsString('position:fixed', (string) $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']));
$post(['name' => 'Heller Abschnitt', 'style' => ['desktop' => ['fontSize' => '32px', 'padding' => ['top' => '40px']]]], $token);
self::assertResponseIsSuccessful();
$presets = json_decode($client->getResponse()->getContent(), true)['presets'];
self::assertSame('heller-abschnitt', $presets[0]['key']);
self::assertSame('32px', $presets[0]['style']['desktop']['fontSize']);
// Removing works by sending the same name without a style.
$post(['name' => 'Heller Abschnitt', 'style' => null], $token);
self::assertSame([], json_decode($client->getResponse()->getContent(), true)['presets']);
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
{
$client = $this->login('editor');
$client->request('GET', '/admin/api/media');
self::assertResponseIsSuccessful();
$token = json_decode($client->getResponse()->getContent(), true)['csrfToken'];
$client->request('POST', '/admin/api/media/upload');
self::assertResponseStatusCodeSame(403);
$path = tempnam(sys_get_temp_dir(), 'kant-image-');
try {
file_put_contents($path, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
$client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'fake.jpg', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]);
self::assertResponseStatusCodeSame(422);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$image = imagecreatetruecolor(1800, 1000);
imagepng($image, $path);
imagedestroy($image);
file_put_contents($path, '<?php SECRET_SOURCE_PAYLOAD ?>', FILE_APPEND);
$client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'example.php', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]);
self::assertResponseStatusCodeSame(201);
$item = json_decode($client->getResponse()->getContent(), true)['item'];
// Four steps now; the last one is capped at the width of the source (1800 here), so a
// picture is never blown up beyond what was uploaded.
self::assertSame([480, 960, 1600, 1800], array_column($item['variants'], 'width'));
foreach ($item['variants'] as $variant) {
$file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
self::assertSame('image/webp', getimagesize($file)['mime']);
self::assertStringNotContainsString('SECRET_SOURCE_PAYLOAD', file_get_contents($file));
}
$update = ['title' => 'Testbild', 'alt' => 'Dunkle Testfläche', 'version' => 1, 'archived' => true];
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
self::assertResponseIsSuccessful();
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
self::assertResponseStatusCodeSame(409);
$client->request('GET', $item['variants'][0]['src']);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'image/webp');
self::assertResponseHeaderSame('X-Content-Type-Options', 'nosniff');
$client->request('GET', '/media/'.$item['id'].'/999.webp');
self::assertResponseStatusCodeSame(404);
$update['version'] = 2;
$update['archived'] = false;
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
self::assertResponseIsSuccessful();
self::assertSame(0, (int) $this->db->fetchOne('SELECT archived FROM media'));
} finally {
unlink($path);
}
}
public function testPdfsGoThroughTheMediaUploadAndTheEditorPicker(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin/media');
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
$path = tempnam(sys_get_temp_dir(), 'kant-form-pdf-');
try {
file_put_contents($path, "%PDF-1.7\n%%EOF\n");
// Dasselbe Feld wie für Bilder: der Inhalt entscheidet, wohin die Datei geht.
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [new UploadedFile($path, 'Flyer.pdf', test: true)]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
self::assertSame('Flyer', $this->db->fetchOne('SELECT title FROM document'));
$crawler = $client->followRedirect();
self::assertStringContainsString('1 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
self::assertCount(1, $crawler->filter('section[aria-labelledby="documents-heading"] .row'));
// Die Auswahl im Editor: Liste mit Token, Hochladen nur mit Token.
$client->request('GET', '/admin/api/dokumente');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame('Flyer', $data['items'][0]['title']);
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'zweites.pdf', test: true)]);
self::assertResponseStatusCodeSame(403);
file_put_contents($path, '<?php echo 1;');
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'getarnt.pdf', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $data['csrfToken']]);
self::assertResponseStatusCodeSame(422);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
// Archivieren nimmt es aus der Auswahl-Übersicht, die Datei bleibt erreichbar.
$id = $data['items'][0]['id'];
$client->request('POST', '/admin/api/dokumente/'.$id, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: json_encode(['title' => 'Flyer 2026', 'archived' => true, 'version' => 1]));
self::assertResponseIsSuccessful();
self::assertCount(0, $client->request('GET', '/admin/media')->filter('section[aria-labelledby="documents-heading"] .row'));
$client->request('GET', '/media/'.$id.'.pdf');
self::assertResponseIsSuccessful();
} finally {
unlink($path);
}
}
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
{
$id = str_repeat('a', 32);
$this->db->insert('media', ['id' => $id, 'title' => 'Kleines Logo', 'alt' => 'Logo', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 1, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
$client = $this->login('editor');
$client->request('GET', '/admin/api/media/'.$id);
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame($id, $data['item']['id']);
self::assertSame(192, $data['item']['variants'][0]['width']);
self::assertTrue($data['item']['archived']);
self::assertArrayNotHasKey('items', $data);
$client->request('GET', '/admin/api/media/'.str_repeat('b', 32));
self::assertResponseStatusCodeSame(404);
}
public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin/media');
self::assertResponseIsSuccessful();
// The images are on the page itself, not behind a button that opens a dialog.
self::assertCount(0, $crawler->filter('.library-tile'));
self::assertCount(1, $crawler->filter('form[action="/admin/media/upload"] input[type="file"]'));
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
$path = tempnam(sys_get_temp_dir(), 'kant-form-image-');
try {
$image = imagecreatetruecolor(900, 600);
imagepng($image, $path);
imagedestroy($image);
$client->request('POST', '/admin/media/upload', ['_csrf_token' => 'wrong'], ['files' => [new UploadedFile($path, 'ohne-token.png', test: true)]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
// Two files in one submit, the way the drop area collects them.
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [
new UploadedFile($path, 'erstes.png', test: true),
new UploadedFile($path, 'zweites.png', test: true),
]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$crawler = $client->followRedirect();
self::assertCount(2, $crawler->filter('.library-tile'));
self::assertCount(1, $crawler->filter('.notice.ok'));
self::assertStringContainsString('2 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
$id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
self::assertResponseRedirects('/admin/media');
self::assertSame(['Bergblick', 'Grüne Berglandschaft', 1], array_values($this->db->fetchNumeric('SELECT title, alt, archived FROM media WHERE id = ?', [$id])));
// An archived image is out of the default listing but still delivered, and the archive
// view shows it again.
self::assertCount(1, $client->request('GET', '/admin/media')->filter('.library-tile'));
self::assertCount(2, $client->request('GET', '/admin/media?archiv=1')->filter('.library-tile'));
// A second save with the same version is refused instead of overwriting the newer one.
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
self::assertResponseRedirects('/admin/media');
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
self::assertCount(1, $client->followRedirect()->filter('.notice.error:not([hidden])'));
} finally {
unlink($path);
}
}
public function testTheMediaScreenPaginatesAndKeepsTheRequestedPageAfterASave(): void
{
$client = $this->login('editor');
for ($number = 1; $number <= 26; ++$number) {
$this->db->insert('media', [
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
'title' => 'Bild '.$number,
'alt' => '',
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
'archived' => 0,
'version' => 1,
// Ascending dates, so "newest first" puts number 26 on the first page.
'created_at' => gmdate('Y-m-d H:i:s', strtotime('2026-09-01 00:00:00') + $number * 60),
]);
}
$first = $client->request('GET', '/admin/media');
self::assertCount(MediaLibrary::PER_PAGE, $first->filter('.library-tile'));
self::assertStringContainsString('Bild 26', $first->filter('.library-tile')->first()->text());
self::assertCount(1, $first->filter('.pager a[rel="next"]'));
self::assertCount(0, $first->filter('.pager a[rel="prev"]'));
$second = $client->request('GET', '/admin/media?seite=2');
self::assertCount(2, $second->filter('.library-tile'));
self::assertStringContainsString('Bild 2', $second->filter('.library-tile')->first()->text());
// A page beyond the end is clamped instead of showing an empty screen.
self::assertCount(2, $client->request('GET', '/admin/media?seite=99')->filter('.library-tile'));
// Saving from page two comes back to page two.
$token = $second->filter('form[action^="/admin/media/000"] input[name="_csrf_token"]')->first()->attr('value');
$id = str_pad('2', 32, '0', STR_PAD_LEFT);
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Umbenannt', 'alt' => '', 'version' => 1, 'seite' => 2]);
self::assertResponseRedirects('/admin/media?seite=2');
}
public function testMediaLiveSearchCoversAllPagesAltTextArchiveAndLiteralWildcards(): void
{
$client = $this->login('editor');
for ($number = 1; $number <= 27; ++$number) {
$this->db->insert('media', [
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
'title' => 1 === $number ? 'Seltenes Motiv 100%_!' : 'Bild '.$number,
'alt' => 2 === $number ? 'Einzigartiger Alternativtext Äußerer Bereich' : '',
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
'archived' => 27 === $number ? 1 : 0,
'version' => 1,
'created_at' => gmdate('Y-m-d H:i:s', 1700000000 + $number),
]);
}
foreach ([['SELTENES', false, 1], ['ALTERNATIVTEXT', false, 1], ['äußerer', false, 1], ['%_!', false, 1], ['Bild', false, 25], ['Bild', true, 26], ['fehlt', false, 0]] as [$query, $archived, $total]) {
$parameters = ['q' => $query, 'archiv' => $archived ? 1 : 0, 'seite' => 99];
$client->request('GET', '/admin/media?'.http_build_query($parameters), server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame($total, $data['total'], $query);
self::assertSame($total > 24 ? 2 : 1, $data['parameters']['seite']);
self::assertStringNotContainsString('<html', $data['html']);
}
// The HTML fallback uses the same global search, and saving retains its context.
$crawler = $client->request('GET', '/admin/media?q=Seltenes');
self::assertCount(1, $crawler->filter('.library-tile'));
$form = $crawler->filter('.library-tile form')->form(['alt' => 'Geändert']);
$client->submit($form);
self::assertResponseRedirects('/admin/media?q=Seltenes');
self::assertCount(1, $client->followRedirect()->filter('.library-tile'));
$crawler = $client->request('GET', '/admin/media?q=0');
$client->submit($crawler->filter('.library-tile form')->first()->form(['alt' => 'Ziffernsuche']));
self::assertResponseRedirects('/admin/media?q=0');
}
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/settings');
// As long as they are missing, the settings screen says so instead of hiding it.
self::assertCount(1, $crawler->filter('form[action="/admin/settings/rechtliches"]'));
self::assertStringContainsString('Pflichtseiten fehlen', $crawler->filter('.card-head')->first()->text());
$token = $crawler->filter('form[action="/admin/settings/rechtliches"] input[name="_csrf_token"]')->attr('value');
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => 'wrong']);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum']));
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]);
self::assertResponseRedirects('/admin/settings');
// Both pages exist, are published, and the settings point at them.
foreach (['datenschutz' => 'Datenschutzerklärung', 'impressum' => 'Impressum'] as $path => $title) {
$row = $this->db->fetchAssociative('SELECT id, published_revision_id FROM page WHERE path = ?', [$path]);
self::assertNotFalse($row, $path);
self::assertNotNull($row['published_revision_id'], $path.' must be published: an unpublished imprint is no imprint.');
$client->request('GET', '/'.$path);
self::assertResponseIsSuccessful();
self::assertStringContainsString($title, $client->getResponse()->getContent());
// The skeleton says plainly that it is not finished.
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
}
// Creating the pages does not alter the author's footer or append a link bar.
$client->request('GET', '/');
$content = $client->getResponse()->getContent();
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
self::assertStringNotContainsString('<a href="/impressum">Impressum</a>', $content);
// Running it twice keeps the existing pages: they may already carry the real text.
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
$client->request('POST', '/admin/settings/rechtliches', ['_csrf_token' => $token]);
self::assertSame($before, $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE path IN (?, ?)', ['datenschutz', 'impressum']));
}
public function testTheStatisticsScreenNeedsASignInAndShowsWhatWasCounted(): void
{
$client = $this->login('editor');
// Two page views and one missing address, counted through real requests.
$client->request('GET', '/');
$client->request('GET', '/');
$client->request('GET', '/gibt-es-nicht');
$crawler = $client->request('GET', '/admin/statistik');
self::assertResponseIsSuccessful();
self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text());
// All three charts - days, hours and weekdays - are drawn by the server, so they are in
// the markup and need no script.
self::assertCount(3, $crawler->filter('svg.chart[role="img"]'));
// Jeder Balken sagt beim Ueberfahren, wofuer er steht - auch das ohne Skript.
self::assertStringContainsString('2 Aufrufe', $crawler->filter('svg.chart-days rect title')->last()->text());
self::assertSame('2', trim($crawler->filter('.stat-tile-main .stat-value')->text()), 'Die Hauptkachel traegt die Summe.');
self::assertSame('1', trim($crawler->filter('.stat-tile')->last()->filter('.stat-value')->text()), 'Die letzte Kachel zaehlt die nicht gefundene Adresse.');
self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.');
self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text());
// And it says what it cannot answer.
self::assertStringContainsString('nicht Besucher', $crawler->filter('.card-body')->last()->text());
$client->request('GET', '/admin/statistik.csv?tage=7');
self::assertResponseIsSuccessful();
self::assertStringContainsString('text/csv', (string) $client->getResponse()->headers->get('Content-Type'));
self::assertStringContainsString('attachment; filename="kantsites-statistik-7-tage.csv"', (string) $client->getResponse()->headers->get('Content-Disposition'));
// The browser has already streamed the body once; the captured copy is the internal one.
$csv = (string) $client->getInternalResponse()->getContent();
self::assertStringContainsString('tag;stunde;pfad;status;verweis-domain;anzahl', $csv);
self::assertStringContainsString(';/;200;;2', $csv);
}
public function testPasswordUpgradeRefreshesTheSessionUser(): void
{
$provider = new UserProvider($this->db);
$user = $provider->loadUserByIdentifier('admin');
$hash = password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 5]);
$provider->upgradePassword($user, $hash);
self::assertSame($hash, $user->getPassword());
self::assertSame($hash, $provider->refreshUser($user)->getPassword());
}
public function testChangedRolesInvalidateAnExistingSession(): void
{
$client = $this->login();
$this->db->executeStatement('UPDATE cms_user SET roles = ? WHERE username = ?', ['["ROLE_EDITOR"]', 'admin']);
$client->request('GET', '/admin/api/pages/p1');
self::assertResponseRedirects('http://localhost/admin/login');
}
public function testLoginCsrfAndPasswordAreRequired(): void
{
$client = self::createClient();
$client->request('POST', '/admin/login', ['_username' => 'admin', '_password' => 'Fixture-only-password']);
self::assertResponseRedirects('/admin/login');
$crawler = $client->followRedirect();
self::assertSelectorExists('[role="alert"]');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'wrong']));
self::assertResponseRedirects('/admin/login');
$client->request('GET', '/admin');
self::assertResponseRedirects('http://localhost/admin/login');
}
public function testCsrfProtectsEveryPostAndMalformedBodyIsRejected(): void
{
$client = $this->login();
$data = $this->data($client);
foreach (['draft', 'publish', 'preview'] as $action) {
$this->post($client, $action, ['document' => $data['document'], 'expectedVersion' => $data['version'], 'revisionId' => $data['revisionId']], 'invalid');
self::assertResponseStatusCodeSame(403);
}
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => '2'], $data['csrfToken']);
self::assertResponseStatusCodeSame(422);
$client->request('POST', '/admin/api/pages/p1/draft', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: '{bad-json');
self::assertResponseStatusCodeSame(422);
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
}
public function testEditorCanSaveButCannotPublish(): void
{
$client = $this->login('editor');
$data = $this->data($client);
$data['document']['title'] = 'Private draft';
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']);
self::assertResponseIsSuccessful();
$saved = json_decode($client->getResponse()->getContent(), true);
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']);
self::assertResponseStatusCodeSame(403);
$client->request('GET', '/');
self::assertStringNotContainsString('Private draft', $client->getResponse()->getContent());
}
public function testPreviewSaveConflictAndPublishRoundTrip(): void
{
$client = $this->login();
$data = $this->data($client);
$document = $data['document'];
$document['blocks'][0]['children'][1]['props']['text'] = 'An edited heading';
$this->post($client, 'preview', ['document' => $document], $data['csrfToken']);
self::assertResponseIsSuccessful();
self::assertStringContainsString('An edited heading', $client->getResponse()->getContent());
self::assertStringContainsString('no-store', $client->getResponse()->headers->get('Cache-Control'));
self::assertStringContainsString('noindex', $client->getResponse()->headers->get('X-Robots-Tag'));
$this->post($client, 'draft', ['document' => $document, 'expectedVersion' => 2], $data['csrfToken']);
self::assertResponseIsSuccessful();
$saved = json_decode($client->getResponse()->getContent(), true);
self::assertSame($document, $saved['document']);
self::assertSame(3, $saved['version']);
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']);
self::assertResponseStatusCodeSame(409);
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $data['revisionId']], $data['csrfToken']);
self::assertResponseStatusCodeSame(409);
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']);
self::assertResponseIsSuccessful();
$client->request('GET', '/');
self::assertStringContainsString('An edited heading', $client->getResponse()->getContent());
self::assertStringNotContainsString('editor.js', $client->getResponse()->getContent());
self::assertFalse($client->getResponse()->headers->has('Set-Cookie'));
}
public function testLogoutInvalidatesSessionAndNeedsCsrf(): void
{
$client = $this->login();
$client->request('POST', '/admin/logout', ['_csrf_token' => 'wrong']);
self::assertResponseStatusCodeSame(403);
$crawler = $client->request('GET', '/admin');
$client->submit($crawler->selectButton('Abmelden')->form());
self::assertResponseRedirects('/admin/login');
$client->request('GET', '/admin');
self::assertResponseRedirects('http://localhost/admin/login');
}
}