"Wieviel wann wo wie die Seite benutzt" - naheliegend wäre ein Analysewerkzeug gewesen, das eine Einwilligung braucht und damit genau die Besucher verliert, die ablehnen. Also andersherum: eine Messung, die niemanden wiedererkennt. Gespeichert wird ein Zähler je (Tag, Stunde, Pfad, Status, Verweis-Domain), kein Datensatz je Aufruf. Keine IP-Adresse, keine Kennung, kein Cookie - auch nicht gehasht, denn der Hash einer IP-Adresse bleibt ein Personenbezug. Ein Test liest die Spaltenliste der Tabelle und vergleicht sie mit dieser Zusage, damit sie beim nächsten Ausbau nicht unbemerkt fällt. Vom Verweis bleibt nur der Host: die volle Adresse kann einen Suchbegriff oder einen Namen tragen. Gezählt wird auf kernel.terminate, nach der Antwort - die Messung darf keine Anfrage verlangsamen, und ein Fehler beim Zählen kostet einen Zähler, nicht eine Seite. Nur GET auf HTML mit 200 oder 404; Verwaltung, Assets, Feed, Sitemap und Einwilligungsseite sind ausgenommen, erkennbare Maschinen über ihre Kennung. Das ist eine Heuristik, deshalb heißt die Zahl auf dem Bildschirm nicht "Besucher". Erfundene Crawler-Adressen laufen gesammelt unter (sonstige), sonst wächst die Tabelle mit jedem geprobten Pfad. Die Diagramme zeichnet der Server als SVG mit Klassen statt Stilattributen: kein Diagrammwerkzeug, kein Skript, und die Richtlinie bleibt streng. Die Zahlen stehen zusätzlich in einer Tabelle, weil ein Balken für einen Screenreader nichts ist. Was die Zahlen nicht sagen, steht auf dem Bildschirm: Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist die Folge der Entscheidung, nicht eine Lücke. Aufbewahrung: nach 60 Tagen werden die Stunden eines Tages verschmolzen, nach 425 Tagen gelöscht - gut ein Jahr, damit der Vorjahresmonat vergleichbar bleibt. cms:stats:prune macht beides und gehört in einen Cronjob. 279 PHP-Tests / 1119 Assertions unter PHP 8.2.12 und 8.4.22, axe auf zwölf Verwaltungsbildschirmen ohne Verstöße. Dabei kam eine PHP-8.4-Verwarnung zu fputcsv() heraus, jetzt mit ausdrücklichem Escape-Parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
730 lines
41 KiB
PHP
730 lines
41 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\SiteSettings;
|
|
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));
|
|
$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', '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;
|
|
}
|
|
|
|
private function login(string $username = 'admin'): KernelBrowser
|
|
{
|
|
$client = self::createClient();
|
|
$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;
|
|
}
|
|
|
|
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 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 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('editor');
|
|
$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"] 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']));
|
|
}
|
|
|
|
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
|
{
|
|
$menus = new MenuRepository($this->db, new SiteSettings($this->db));
|
|
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
|
|
|
$client = $this->login('editor');
|
|
$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']));
|
|
}
|
|
|
|
private function contactForm(): 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.'], 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));
|
|
$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' => hash_hmac('sha256', $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('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'));
|
|
|
|
$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 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'));
|
|
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 => hash_hmac('sha256', $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(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
|
}
|
|
|
|
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
|
{
|
|
$client = $this->login('editor');
|
|
$crawler = $client->request('GET', '/admin/forms');
|
|
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 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'];
|
|
self::assertSame([480, 960, 1600], 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 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 Bild(er) 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'));
|
|
} 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 testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
|
{
|
|
$client = $this->login('editor');
|
|
$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());
|
|
}
|
|
|
|
// And they are linked from an ordinary page, without anyone editing the footer.
|
|
$client->request('GET', '/');
|
|
$content = $client->getResponse()->getContent();
|
|
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
|
self::assertStringContainsString('<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());
|
|
// Both charts - days and hours - are drawn by the server, so they are in the markup and
|
|
// need no script.
|
|
self::assertCount(2, $crawler->filter('svg.chart[role="img"]'));
|
|
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');
|
|
}
|
|
}
|